Danh sách bài viết

Bài 69: Cart Endpoints — Session Cart + Checkout Flow

Bài 69 của series Rust RESTful API — bài CODE thực tế lớn nối tiếp B68 (GET /orders/{id} detail + timeline polling-friendly + PATCH limited note + ETag + tokio::join parallel 3 query + audit_logs mapping + migration 11 ADD note + authorization preview B112): triển khai 5 endpoint cart DB-backed (GET /api/v1/cart trả full cart + items + subtotal + item_count, POST /api/v1/cart/items thêm item với UPSERT pattern ON CONFLICT DO UPDATE dup product → increment quantity, PATCH /api/v1/cart/items/{id} update quantity absolute, DELETE /api/v1/cart/items/{id} remove single item, DELETE /api/v1/cart clear toàn bộ) + 1 endpoint POST /api/v1/cart/checkout convert cart → order qua create_order_atomic B54 reuse + Idempotency middleware B66 + clear cart sau success; phân biệt 2 model cart server-side persistent (Shop API lock) vs session-only client (alternative) — lock decision DB-backed B69 với Redis migration G15 preview (TTL 30 ngày Project Spec); migration 12 mới 20260616110000_create_carts.sql tạo bảng carts 6 cột (id BIGSERIAL + user_id BIGINT nullable + session_id TEXT nullable + 3 timestamp + CHECK constraint user_id IS NOT NULL OR session_id IS NOT NULL) + 2 UNIQUE partial index WHERE NOT NULL lock 1 cart per identity + index expires_at cleanup; bảng cart_items 6 cột (id BIGSERIAL + cart_id FK CASCADE + product_id FK CASCADE + quantity INT CHECK > 0 + 2 timestamp) + UNIQUE (cart_id, product_id) lock chống duplicate product trong cart; module crates/shop-db/src/carts.rs mới với CartRow 6 field + CartItemRow 9 field (bao gồm product_name + product_slug + unit_price + available_stock JOIN từ products denormalize cho client UI), 7 hàm DB get_or_create_cart upsert auto-create + list_cart_items JOIN products filter deleted_at IS NULL + add_or_update_item UPSERT ON CONFLICT DO UPDATE quantity sum + update_item_quantity set absolute + remove_item + clear_cart + merge_carts_on_login preview B112 transaction wrap consolidation guest → user; DTO mới CartResponseDto 5 field + CartItemResponseDto 10 field với is_available flag client UI render warning khi available_stock < quantity + AddCartItemDto 2 field validate range 1-100 + UpdateCartItemDto 1 field + CheckoutDto 2 field (payment_method + note?); pattern stock availability check tại cart KHÔNG decrement (chỉ check + warn) — decrement chỉ tại create_order_atomic B54 (FOR UPDATE pessimistic row lock) tránh ghost reservation; checkout flow 6 step lock B69 (get cart + verify stock + convert cart items → order items + create_order_atomic wrap with_retry B55 SerializationFailure + clear cart AFTER order success idempotent retry safe + build response 201 Created + Location); cart consolidation merge guest cart → user cart khi login (B112 deep) qua transaction wrap 4 step (find guest cart by session_id + UPSERT user cart + INSERT cart_items SELECT FROM guest ON CONFLICT quantity sum + DELETE guest cart CASCADE clean), rule merge MANDATORY quantity sum KHÔNG max KHÔNG overwrite — UX rule lock B69; endpoint singular /cart (ngoại lệ B61 plural rule vì 1 user 1 cart) + /cart/items plural cho collection; KHÔNG audit_logs cho cart (ephemeral data lock B65 continued); foundation cho B70 (users register + profile + password argon2 + email verification), B112 (JWT auth wire user_id + merge_carts_on_login khi login), G15 (Redis cache migration cart từ DB sang cart:<user_id> namespace TTL Redis-managed), G18 (cron job DELETE expired carts hàng đêm).

16/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ẽ:

  • Hiểu 2 model cart: server-side persistent (DB-backed hoặc Redis) vs session-only client storage (localStorage/cookie) — pros/cons mỗi cách và lý do Shop API chọn server-side.
  • Implement DB-backed cart Shop API với migration 12 mới tạo bảng carts + cart_items bridge — Redis migration G15 preview với TTL Redis-managed.
  • Implement 5 endpoint cart: GET /cart, POST /cart/items, PATCH /cart/items/{id}, DELETE /cart/items/{id}, DELETE /cart.
  • Implement checkout flow 6 step: convert cart → order qua POST /cart/checkout reuse create_order_atomic B54 + Idempotency middleware B66 + clear cart sau success.
  • Áp dụng pattern stock availability check tại cart (KHÔNG decrement stock, chỉ check available + flag is_available) — decrement stock chỉ tại checkout.
  • Áp dụng pattern cart consolidation merge guest cart → user cart khi login (B112 preview) — UX rule lock quantity sum (KHÔNG max, KHÔNG overwrite).
  • Migration 12 carts + cart_items với CHECK constraint (user_id OR session_id), UNIQUE WHERE NOT NULL partial index (1 cart per identity), UNIQUE (cart_id, product_id) chống duplicate.
  • Foundation cho B70 (users register + profile), B112 (JWT auth wire user_id + merge_carts_on_login), G15 (Redis cache migration), G18 (cron job DELETE expired carts).
2

2 Model Cart — Server Vs Session Storage

Industry chia 2 hướng implement giỏ hàng e-commerce, mỗi hướng có trade-off rõ ràng.

Server-side persistent cart — lưu trữ giỏ hàng trên backend, định danh qua user_id (đã login) hoặc session_id (guest). Backend storage có 2 lựa chọn: PostgreSQL bảng carts + cart_items (B69 lock) hoặc Redis với key cart:<user_id> JSON serialize (G15 migration). Pros: (a) cross-device sync — user mở app mobile thấy cùng cart đã thêm trên web; (b) không lose khi clear browser — khác localStorage bị xóa khi user clear cache hoặc reset device; (c) analytics-friendly — backend track cart abandonment rate, remarketing email "bạn quên cart"; (d) checkout consistency — server validate stock + price ngay tại API boundary thay tin tưởng client. Cons: (i) cần auth hoặc session_id cookie cho guest; (ii) storage cost tỷ lệ user × avg item; (iii) latency thêm round-trip API mỗi thao tác.

Session-only client cart — lưu giỏ hàng trong localStorage trình duyệt hoặc cookie. Pros: (a) không cần auth — user lướt anonymously vẫn thêm cart bình thường; (b) simple — backend chỉ expose product API, cart hoàn toàn client-side; (c) latency 0 — đọc/ghi localStorage ~1ms không round-trip server. Cons: (i) lose khi clear browser — user mất cart nếu clear cache, reset device, đổi trình duyệt; (ii) không sync cross-device — mỗi thiết bị 1 cart riêng, UX gãy; (iii) storage cap 5-10MB mỗi origin localStorage; (iv) không validate — client tự tính price/stock, dễ tampering qua devtools.

Lock decision Shop API B69 — server-side persistent với DB-backed (PostgreSQL). 4 lý do:

  • Cross-device sync MANDATORY cho e-commerce hiện đại — user research thấy 60-70% checkout xảy ra trên thiết bị KHÁC với thiết bị browse (theo Google Mobile Path-to-Purchase 2024).
  • Remarketing email "abandoned cart" bắt buộc dữ liệu cart ở backend — workflow gửi mail sau 24h bỏ rơi cart (G24 notification job) tăng conversion 10-30%.
  • Stock validation chính xác — backend single source of truth về products.stock, tránh client cache stock cũ cho phép thêm vượt available.
  • Redis migration G15 sẵn sàng — pattern DB-backed B69 abstract qua module shop-db::carts, G15 swap implementation sang shop-cache::carts dùng Redis key cart:<user_id> hash với TTL 30 ngày Redis-managed (Project Spec G6 lock). Schema PostgreSQL B69 phù hợp test pattern đầy đủ trước khi optimize latency với Redis.

Guest cart support: dùng session_id cookie (UUID v4) sinh ra phía server lần đầu truy cập, lưu vào browser cookie HttpOnly + Secure + SameSite=Lax; backend tạo cart với session_id field NOT NULL + user_id NULL. Khi user login, merge guest cart vào user cart (B112 deep, preview Bước 7).

3

Migration carts + cart_items Table

Tạo migration 12 (sau B66 idempotency_keys = 10 + B68 add_orders_note = 11):

cargo sqlx migrate add --source crates/shop-db/migrations create_carts
# → tạo file crates/shop-db/migrations/20260616110000_create_carts.sql

Nội dung file migration:

-- File: crates/shop-db/migrations/20260616110000_create_carts.sql

CREATE TABLE carts (
    id          BIGSERIAL PRIMARY KEY,
    user_id     BIGINT,                                    -- NULL cho guest
    session_id  TEXT,                                      -- NULL cho user logged in
    created_at  TIMESTAMPTZ NOT NULL DEFAULT NOW(),
    updated_at  TIMESTAMPTZ NOT NULL DEFAULT NOW(),
    expires_at  TIMESTAMPTZ NOT NULL DEFAULT (NOW() + INTERVAL '30 days'),
    CHECK (user_id IS NOT NULL OR session_id IS NOT NULL)
);

-- 1 cart per user (chỉ khi user_id NOT NULL)
CREATE UNIQUE INDEX carts_user_unique_idx
    ON carts(user_id) WHERE user_id IS NOT NULL;

-- 1 cart per guest session
CREATE UNIQUE INDEX carts_session_unique_idx
    ON carts(session_id) WHERE session_id IS NOT NULL;

-- Cleanup cron job (G18)
CREATE INDEX carts_expires_idx ON carts(expires_at);

CREATE TRIGGER carts_updated_at
    BEFORE UPDATE ON carts
    FOR EACH ROW
    EXECUTE FUNCTION update_updated_at_column();

CREATE TABLE cart_items (
    id          BIGSERIAL PRIMARY KEY,
    cart_id     BIGINT NOT NULL REFERENCES carts(id) ON DELETE CASCADE,
    product_id  BIGINT NOT NULL REFERENCES products(id) ON DELETE CASCADE,
    quantity    INT NOT NULL CHECK (quantity > 0),
    added_at    TIMESTAMPTZ NOT NULL DEFAULT NOW(),
    updated_at  TIMESTAMPTZ NOT NULL DEFAULT NOW(),
    UNIQUE (cart_id, product_id)                           -- chống duplicate
);

CREATE INDEX cart_items_cart_idx ON cart_items(cart_id);
CREATE INDEX cart_items_product_idx ON cart_items(product_id);

CREATE TRIGGER cart_items_updated_at
    BEFORE UPDATE ON cart_items
    FOR EACH ROW
    EXECUTE FUNCTION update_updated_at_column();

6 lock decision schema migration 12:

  • 2 identity nullable user_id + session_id đều NULLABLE — chấp nhận guest (chỉ session_id) hoặc user logged in (chỉ user_id); CHECK constraint user_id IS NOT NULL OR session_id IS NOT NULL đảm bảo ít nhất 1 trong 2 có giá trị, KHÔNG cho phép cart ma không thuộc về ai.
  • UNIQUE WHERE NOT NULL partial index pattern lock — carts_user_unique_idx chỉ index row có user_id NOT NULL đảm bảo 1 cart per user; carts_session_unique_idx tương tự cho guest. Pattern này tương đương "conditional UNIQUE" trong SQL chuẩn nhưng tận dụng Postgres partial index hiệu suất cao + tránh false conflict NULL = NULL (Postgres treat NULL khác nhau ở UNIQUE NULLS DISTINCT mặc định B16).
  • expires_at default 30 ngày — TTL lock B69 vĩnh viễn theo Project Spec; cron job G18 chạy hàng đêm DELETE FROM carts WHERE expires_at < NOW() CASCADE clean cart_items; user thao tác cart trigger UPDATE refresh updated_at + extend expires_at qua trigger logic mở rộng (mở rộng update_updated_at_column() B62 thêm logic NEW.expires_at = NOW() + INTERVAL '30 days') hoặc set thủ công tại service layer Rust (B69 chọn explicit Rust để tránh logic phức tạp trong trigger PostgreSQL).
  • UNIQUE (cart_id, product_id) cart_items lock vĩnh viễn — KHÔNG cho phép 2 row cùng cart cùng product; add same product → UPSERT pattern ON CONFLICT DO UPDATE tăng quantity thay INSERT mới (deep dive Bước 4). UX rule: cart luôn hiển thị mỗi product 1 dòng với quantity tổng.
  • FK CASCADE 2 chiều: cart_id REFERENCES carts ON DELETE CASCADE (xóa cart → xóa cart_items tự động) + product_id REFERENCES products ON DELETE CASCADE (xóa product hard-delete → cart_items chứa product đó tự xóa). Defense in depth tránh orphan rows; lưu ý products dùng soft delete (B62) nên hard-delete hiếm xảy ra, FK CASCADE chỉ kích hoạt khi admin force purge data GDPR.
  • Trigger updated_at reuse update_updated_at_column() shared function B62 — auto-refresh mọi UPDATE cart + cart_items; KHÔNG set thủ công Rust handler nhất quán pattern lock B62 continued.

Apply migration + regen sqlx cache:

cargo sqlx migrate run --source crates/shop-db/migrations
# → Applied 20260616110000/migrate create carts

cargo sqlx prepare --workspace
# → cập nhật .sqlx/ cache cho query mới
4

shop-db::carts Module — 7 Hàm Core

Tạo module mới crates/shop-db/src/carts.rs với 2 row type + 7 hàm DB. Đăng ký module trong crates/shop-db/src/lib.rs qua pub mod carts;.

// File: crates/shop-db/src/carts.rs

use chrono::{DateTime, Utc};
use rust_decimal::Decimal;
use sqlx::{PgPool, Postgres, Transaction};

pub struct CartRow {
    pub id: i64,
    pub user_id: Option<i64>,
    pub session_id: Option<String>,
    pub created_at: DateTime<Utc>,
    pub updated_at: DateTime<Utc>,
    pub expires_at: DateTime<Utc>,
}

pub struct CartItemRow {
    pub id: i64,
    pub cart_id: i64,
    pub product_id: i64,
    pub product_name: String,         // JOIN từ products
    pub product_slug: String,         // JOIN từ products
    pub unit_price: Decimal,          // JOIN từ products.price
    pub quantity: i32,
    pub available_stock: i32,         // JOIN từ products.stock
    pub added_at: DateTime<Utc>,
}

Hàm 1 — get_or_create_cart: upsert auto-create nếu chưa có. Pattern này tránh client phải gọi POST /cart create riêng trước khi POST /cart/items, đơn giản UX.

pub async fn get_or_create_cart(
    pool: &PgPool,
    user_id: Option<i64>,
    session_id: Option<&str>,
) -> Result<CartRow, sqlx::Error> {
    // Look up existing
    let existing = match (user_id, session_id) {
        (Some(uid), _) => sqlx::query_as!(
            CartRow,
            r#"SELECT id, user_id, session_id, created_at, updated_at, expires_at
               FROM carts WHERE user_id = $1"#,
            uid
        )
        .fetch_optional(pool)
        .await?,
        (None, Some(sid)) => sqlx::query_as!(
            CartRow,
            r#"SELECT id, user_id, session_id, created_at, updated_at, expires_at
               FROM carts WHERE session_id = $1"#,
            sid
        )
        .fetch_optional(pool)
        .await?,
        (None, None) => {
            return Err(sqlx::Error::Configuration(
                "either user_id or session_id required".into(),
            ));
        }
    };

    if let Some(cart) = existing {
        return Ok(cart);
    }

    // Create new
    sqlx::query_as!(
        CartRow,
        r#"
        INSERT INTO carts (user_id, session_id)
        VALUES ($1, $2)
        RETURNING id, user_id, session_id, created_at, updated_at, expires_at
        "#,
        user_id,
        session_id
    )
    .fetch_one(pool)
    .await
}

Hàm 2 — list_cart_items: JOIN products lấy product_name + slug + price + stock để client UI render đầy đủ không cần N+1 call (pattern denormalize lock B66 continued).

pub async fn list_cart_items(
    pool: &PgPool,
    cart_id: i64,
) -> Result<Vec<CartItemRow>, sqlx::Error> {
    sqlx::query_as!(
        CartItemRow,
        r#"
        SELECT
            ci.id, ci.cart_id, ci.product_id, ci.quantity, ci.added_at,
            p.name  AS product_name,
            p.slug  AS product_slug,
            p.price AS unit_price,
            p.stock AS available_stock
        FROM cart_items ci
        JOIN products p ON p.id = ci.product_id
        WHERE ci.cart_id = $1 AND p.deleted_at IS NULL
        ORDER BY ci.added_at DESC
        "#,
        cart_id
    )
    .fetch_all(pool)
    .await
}

Hàm 3 — add_or_update_item: UPSERT pattern lock B69 vĩnh viễn. SQL ON CONFLICT (cart_id, product_id) DO UPDATE tận dụng UNIQUE index step 3, gọi 1 statement atomic thay 2 statement (SELECT check exist + INSERT/UPDATE conditional) race condition prone.

pub async fn add_or_update_item(
    pool: &PgPool,
    cart_id: i64,
    product_id: i64,
    quantity: i32,
) -> Result<i64, sqlx::Error> {
    let row = sqlx::query!(
        r#"
        INSERT INTO cart_items (cart_id, product_id, quantity)
        VALUES ($1, $2, $3)
        ON CONFLICT (cart_id, product_id)
        DO UPDATE SET quantity = cart_items.quantity + EXCLUDED.quantity
        RETURNING id
        "#,
        cart_id,
        product_id,
        quantity
    )
    .fetch_one(pool)
    .await?;
    Ok(row.id)
}

EXCLUDED.quantity là giá trị từ row INSERT bị conflict; biểu thức cart_items.quantity + EXCLUDED.quantity tính tổng quantity cũ + quantity mới — semantic "add to cart 2 lần, mỗi lần 1 cái → cart có 2 cái".

Hàm 4 — update_item_quantity: set quantity absolute (replace value, KHÔNG cộng dồn) — semantic khác add_or_update_item; UX khi user kéo quantity slider hiện 3 lên 5 thì cart phải có quantity = 5, không phải = 8.

pub async fn update_item_quantity(
    pool: &PgPool,
    cart_id: i64,
    item_id: i64,
    quantity: i32,
) -> Result<u64, sqlx::Error> {
    let result = sqlx::query!(
        "UPDATE cart_items SET quantity = $1 WHERE id = $2 AND cart_id = $3",
        quantity,
        item_id,
        cart_id
    )
    .execute(pool)
    .await?;
    Ok(result.rows_affected())
}

Hàm 5-6 — remove_item + clear_cart: DELETE đơn giản, trả về rows_affected để handler phân biệt 404 (item không tồn tại) vs 204 (xóa OK).

pub async fn remove_item(
    pool: &PgPool,
    cart_id: i64,
    item_id: i64,
) -> Result<u64, sqlx::Error> {
    let result = sqlx::query!(
        "DELETE FROM cart_items WHERE id = $1 AND cart_id = $2",
        item_id,
        cart_id
    )
    .execute(pool)
    .await?;
    Ok(result.rows_affected())
}

pub async fn clear_cart(pool: &PgPool, cart_id: i64) -> Result<u64, sqlx::Error> {
    let result = sqlx::query!(
        "DELETE FROM cart_items WHERE cart_id = $1",
        cart_id
    )
    .execute(pool)
    .await?;
    Ok(result.rows_affected())
}

Hàm 7 — merge_carts_on_login: cart consolidation guest → user. Deep dive ở Bước 7. KHÔNG audit_logs cho cart (ephemeral, lock B65 continued).

5

DTO + 5 Endpoint Handler

Tạo file mới crates/shop-common/src/dto/cart.rs:

// File: crates/shop-common/src/dto/cart.rs

use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use validator::Validate;

use super::{Money, ProductId};

#[derive(Debug, Clone, Serialize)]
pub struct CartResponseDto {
    pub id: i64,
    pub items: Vec<CartItemResponseDto>,
    pub subtotal: Money,            // tổng giá tất cả items
    pub item_count: u32,            // sum quantity
    pub expires_at: DateTime<Utc>,
}

#[derive(Debug, Clone, Serialize)]
pub struct CartItemResponseDto {
    pub id: i64,
    pub product_id: ProductId,
    pub product_name: String,
    pub product_slug: String,
    pub unit_price: Money,
    pub quantity: u32,
    pub subtotal: Money,            // unit_price × quantity
    pub available_stock: u32,
    pub is_available: bool,         // false nếu stock < quantity
    pub added_at: DateTime<Utc>,
}

#[derive(Debug, Clone, Deserialize, Validate)]
pub struct AddCartItemDto {
    pub product_id: ProductId,

    #[validate(range(min = 1, max = 100, message = "quantity phải trong khoảng 1-100"))]
    pub quantity: u32,
}

#[derive(Debug, Clone, Deserialize, Validate)]
pub struct UpdateCartItemDto {
    #[validate(range(min = 1, max = 100, message = "quantity phải trong khoảng 1-100"))]
    pub quantity: u32,
}

Re-export trong crates/shop-common/src/dto/mod.rs:

// File: crates/shop-common/src/dto/mod.rs (extend)

pub mod cart;

pub use cart::{
    AddCartItemDto, CartItemResponseDto, CartResponseDto, UpdateCartItemDto,
};

Tạo handler crates/shop-api/src/routes/cart.rs:

// File: crates/shop-api/src/routes/cart.rs

use axum::extract::{Path, State};
use axum::routing::{delete, get, patch, post};
use axum::{Json, Router};
use rust_decimal::Decimal;
use shop_common::dto::{
    AddCartItemDto, CartItemResponseDto, CartResponseDto, Money, ProductId,
    UpdateCartItemDto,
};
use shop_common::error::AppError;
use shop_db::carts as db;

use crate::extractors::ValidatedJson;
use crate::responses::NoContent;
use crate::state::AppState;

// TODO B112: extract qua Extension<CurrentUser>
const PLACEHOLDER_USER_ID: i64 = 1;

pub async fn get_cart(
    State(state): State<AppState>,
) -> Result<Json<CartResponseDto>, AppError> {
    let cart = db::get_or_create_cart(&state.db, Some(PLACEHOLDER_USER_ID), None).await?;
    let items = db::list_cart_items(&state.db, cart.id).await?;
    Ok(Json(build_cart_response(cart, items)))
}

pub async fn add_item(
    State(state): State<AppState>,
    ValidatedJson(dto): ValidatedJson<AddCartItemDto>,
) -> Result<Json<CartResponseDto>, AppError> {
    let cart = db::get_or_create_cart(&state.db, Some(PLACEHOLDER_USER_ID), None).await?;
    db::add_or_update_item(
        &state.db,
        cart.id,
        dto.product_id.0 as i64,
        dto.quantity as i32,
    )
    .await?;

    // Re-fetch full cart sau UPSERT để client thấy state mới
    let items = db::list_cart_items(&state.db, cart.id).await?;
    Ok(Json(build_cart_response(cart, items)))
}

pub async fn update_item(
    State(state): State<AppState>,
    Path(item_id): Path<i64>,
    ValidatedJson(dto): ValidatedJson<UpdateCartItemDto>,
) -> Result<Json<CartResponseDto>, AppError> {
    let cart = db::get_or_create_cart(&state.db, Some(PLACEHOLDER_USER_ID), None).await?;
    let affected = db::update_item_quantity(
        &state.db,
        cart.id,
        item_id,
        dto.quantity as i32,
    )
    .await?;
    if affected == 0 {
        return Err(AppError::NotFound(format!(
            "cart item {} not found",
            item_id
        )));
    }
    let items = db::list_cart_items(&state.db, cart.id).await?;
    Ok(Json(build_cart_response(cart, items)))
}

pub async fn remove_item(
    State(state): State<AppState>,
    Path(item_id): Path<i64>,
) -> Result<NoContent, AppError> {
    let cart = db::get_or_create_cart(&state.db, Some(PLACEHOLDER_USER_ID), None).await?;
    let affected = db::remove_item(&state.db, cart.id, item_id).await?;
    if affected == 0 {
        return Err(AppError::NotFound(format!(
            "cart item {} not found",
            item_id
        )));
    }
    Ok(NoContent)
}

pub async fn clear_cart(
    State(state): State<AppState>,
) -> Result<NoContent, AppError> {
    let cart = db::get_or_create_cart(&state.db, Some(PLACEHOLDER_USER_ID), None).await?;
    db::clear_cart(&state.db, cart.id).await?;
    Ok(NoContent)
}

pub fn build_cart_response(
    cart: db::CartRow,
    items: Vec<db::CartItemRow>,
) -> CartResponseDto {
    let mut subtotal = Decimal::ZERO;
    let mut item_count: u32 = 0;

    let items_dto: Vec<CartItemResponseDto> = items
        .into_iter()
        .map(|i| {
            let item_subtotal = i.unit_price * Decimal::from(i.quantity);
            subtotal += item_subtotal;
            item_count += i.quantity as u32;

            CartItemResponseDto {
                id: i.id,
                product_id: ProductId(i.product_id as u64),
                product_name: i.product_name,
                product_slug: i.product_slug,
                unit_price: Money(i.unit_price),
                quantity: i.quantity as u32,
                subtotal: Money(item_subtotal),
                available_stock: i.available_stock as u32,
                is_available: i.available_stock >= i.quantity,
                added_at: i.added_at,
            }
        })
        .collect();

    CartResponseDto {
        id: cart.id,
        items: items_dto,
        subtotal: Money(subtotal),
        item_count,
        expires_at: cart.expires_at,
    }
}

pub fn routes() -> Router<AppState> {
    Router::new()
        .route("/cart", get(get_cart).delete(clear_cart))
        .route("/cart/items", post(add_item))
        .route(
            "/cart/items/{item_id}",
            patch(update_item).delete(remove_item),
        )
}

4 lock decision URL + handler:

  • Endpoint singular /cart — ngoại lệ B61 lock rule (plural cho collection) vì 1 user chỉ có 1 cart duy nhất, singular semantic chính xác hơn; tương đương /me, /profile. Sub-resource /cart/items giữ plural đúng quy tắc cho collection.
  • is_available flag client UI render — khi available_stock < quantity (vd user thêm 5 vào cart, sau đó admin restock chỉ còn 2), UI hiển thị warning "Còn 2 sản phẩm trong kho, vui lòng giảm số lượng" + nút "Cập nhật" disable checkout cho đến khi adjust quantity về <= available_stock. Lock pattern cho mọi cart Shop API tương lai.
  • Re-fetch full cart sau mutate — POST add_item + PATCH update_item trả về CartResponseDto đầy đủ (KHÔNG chỉ riêng item), tiết kiệm client 1 GET round-trip để refresh UI sau thao tác; pattern UX optimistic update + reconcile.
  • 404 vs 204 distinction — DELETE /cart/items/{id} trả 404 nếu item không tồn tại trong cart user (defensive: tránh false-success UX), trả 204 NoContent nếu xóa thành công. DELETE /cart luôn trả 204 (idempotent clear, xóa cart rỗng vẫn OK).

Wire route trong crates/shop-api/src/router.rs bằng .merge(routes::cart::routes()) vào nest /api/v1.

6

Checkout Flow — Convert Cart → Order

Checkout là điểm nối cart → order. Pattern lock B69 vĩnh viễn: cart KHÔNG decrement stock (chỉ check available + flag is_available), decrement stock chỉ tại create_order_atomic B54 (FOR UPDATE pessimistic row lock guarantee atomic). Lý do: nếu cart decrement stock thì 100 user thêm cart đồng thời sẽ "khóa" stock 100 unit mà chưa thực sự mua → ghost reservation vô hạn (user bỏ rơi cart vĩnh viễn → stock không bao giờ free); ngoài ra cron job cleanup expired cart phải restore stock thêm phức tạp.

DTO CheckoutDto thêm vào dto/cart.rs:

// File: crates/shop-common/src/dto/cart.rs (extend)

use super::PaymentMethod;        // B43 lock

#[derive(Debug, Clone, Deserialize, Validate)]
pub struct CheckoutDto {
    #[validate(nested)]
    pub payment_method: PaymentMethod,

    #[serde(default)]
    #[validate(length(max = 500, message = "note tối đa 500 ký tự"))]
    pub note: Option<String>,
}

Handler checkout 6 step:

// File: crates/shop-api/src/routes/cart.rs (extend)

use shop_common::dto::{CheckoutDto, OrderResponseDto, PaymentMethod};
use shop_common::retry::with_retry;
use shop_db::orders as orders_db;

use crate::responses::Created;

pub async fn checkout(
    State(state): State<AppState>,
    ValidatedJson(dto): ValidatedJson<CheckoutDto>,
) -> Result<Created<OrderResponseDto>, AppError> {
    let user_id = PLACEHOLDER_USER_ID;

    // Step 1: get cart + items
    let cart = db::get_or_create_cart(&state.db, Some(user_id), None).await?;
    let items = db::list_cart_items(&state.db, cart.id).await?;

    if items.is_empty() {
        return Err(AppError::Validation(
            "cart empty, cannot checkout".into(),
        ));
    }

    // Step 2: verify stock available (final pre-flight check)
    for item in &items {
        if item.available_stock < item.quantity {
            return Err(AppError::Validation(format!(
                "product {} insufficient stock: requested {}, available {}",
                item.product_slug, item.quantity, item.available_stock
            )));
        }
    }

    // Step 3: convert cart items → order items
    let order_items: Vec<orders_db::CreateOrderItem> = items
        .iter()
        .map(|i| orders_db::CreateOrderItem {
            product_id: i.product_id,
            quantity: i.quantity,
        })
        .collect();

    let payment_type = match &dto.payment_method {
        PaymentMethod::Stripe { .. } => "stripe",
        PaymentMethod::BankTransfer { .. } => "bank_transfer",
        PaymentMethod::Cod { .. } => "cod",
    };
    let payment_payload = serde_json::to_value(&dto.payment_method)?;

    // Step 4: create_order_atomic + retry SerializationFailure (B55 lock)
    let pool = state.db.clone();
    let items_arg = order_items.clone();
    let payload_arg = payment_payload.clone();
    let order = with_retry(
        || async {
            orders_db::create_order_atomic(
                &pool,
                user_id,
                items_arg.clone(),
                payment_type,
                payload_arg.clone(),
            )
            .await
            .map_err(AppError::from)
        },
        3,
    )
    .await?;

    // Step 5: clear cart AFTER order success (idempotent retry safe)
    db::clear_cart(&state.db, cart.id).await?;

    // Step 6: build response 201 + Location
    let response = super::orders::build_order_response(
        &state.db,
        order,
        dto.note,
    )
    .await?;
    let location = format!("/api/v1/orders/{}", response.id.0);
    Ok(Created { location, data: response })
}

5 lock decision checkout flow:

  • Stock check 2 lần — Step 2 pre-flight check tại cart (cảnh báo sớm UX, return 422 trước khi vào transaction nặng); decrement thực sự diễn ra tại create_order_atomic B54 với FOR UPDATE row lock guarantee atomic — phòng race condition giữa Step 2 và Step 4 (user khác mua hết stock cùng lúc). Defense in depth.
  • Clear cart AFTER order success (Step 5 sau Step 4) — idempotent retry safe: nếu Step 4 fail → cart giữ nguyên, user retry checkout không mất items; nếu Step 4 succeed nhưng Step 5 fail (lỗi network sau commit order) → user có order + cart vẫn còn, cron job G18 cleanup expired cart sẽ tự xóa sau 30 ngày HOẶC user gọi DELETE /cart thủ công. Không bao giờ clear cart TRƯỚC order create (nếu order fail → user mất cả cart + chưa có order, UX gãy).
  • with_retry SerializationFailure (B55 lock continued) — wrap create_order_atomic retry tối đa 3 attempts backoff 10/20/40ms cho SQLSTATE 40001/40P01 transient; permanent error 23xxx fail-fast.
  • Endpoint POST /api/v1/cart/checkout — verb-action pattern lock B61 cho transition không map CRUD chuẩn; alternative POST /api/v1/checkout top-level (lock Project Spec) tương đương semantic nhưng B69 dùng /cart/checkout đảm bảo discoverability dưới namespace cart. Áp dụng Idempotency middleware MANDATORY (lock B66 continued cho mọi mutation financial-impact) qua .layer(from_fn_with_state(state.clone(), idempotency_middleware)) selective per-route — checkout phải idempotent vì user tap nút "Thanh toán" 2 lần trên mobile mạng chậm KHÔNG tạo 2 order.
  • Reuse build_order_response B66 — handler thin pattern 30-50 dòng/endpoint lock; trả full OrderResponseDto với items denormalize + payment.payload_safe sanitized PII; client redirect sang trang order detail dùng Location header.
7

Cart Consolidation — Guest → User Login

Use case: user A lướt anonymously (session_id cookie), thêm 2 sản phẩm vào guest cart; sau đó user A login → backend phát hiện account đã có user cart (vd 1 sản phẩm cũ từ thiết bị khác trước đó) → cần merge 2 cart thành 1, KHÔNG để guest cart "mồ côi" hoặc overwrite user cart.

Hàm merge_carts_on_login trong crates/shop-db/src/carts.rs:

// File: crates/shop-db/src/carts.rs (tiếp)

pub async fn merge_carts_on_login(
    pool: &PgPool,
    user_id: i64,
    session_id: &str,
) -> Result<(), sqlx::Error> {
    let mut tx: Transaction<'_, Postgres> = pool.begin().await?;

    // 1. Find guest cart
    let guest_cart: Option<i64> = sqlx::query_scalar!(
        "SELECT id FROM carts WHERE session_id = $1",
        session_id
    )
    .fetch_optional(&mut *tx)
    .await?;

    let Some(guest_id) = guest_cart else {
        tx.commit().await?;
        return Ok(()); // KHÔNG có guest cart, skip
    };

    // 2. Find or create user cart (UPSERT)
    let user_cart_id: i64 = sqlx::query_scalar!(
        r#"
        INSERT INTO carts (user_id)
        VALUES ($1)
        ON CONFLICT (user_id) WHERE user_id IS NOT NULL
        DO UPDATE SET updated_at = NOW()
        RETURNING id
        "#,
        user_id
    )
    .fetch_one(&mut *tx)
    .await?;

    // 3. Merge items: UPSERT từ guest → user, quantity SUM
    sqlx::query!(
        r#"
        INSERT INTO cart_items (cart_id, product_id, quantity)
        SELECT $1, product_id, quantity
        FROM cart_items WHERE cart_id = $2
        ON CONFLICT (cart_id, product_id)
        DO UPDATE SET quantity = cart_items.quantity + EXCLUDED.quantity
        "#,
        user_cart_id,
        guest_id
    )
    .execute(&mut *tx)
    .await?;

    // 4. Delete guest cart (CASCADE auto-clean cart_items)
    sqlx::query!("DELETE FROM carts WHERE id = $1", guest_id)
        .execute(&mut *tx)
        .await?;

    tx.commit().await?;
    Ok(())
}

Hàm này sẽ được gọi từ handler POST /api/v1/auth/login B112 sau khi verify password thành công, lấy session_id từ cookie request + user_id vừa authenticate:

// Preview B112: handler login (chưa implement)
//
// if let Some(sid) = jar.get("session_id").map(|c| c.value().to_string()) {
//     shop_db::carts::merge_carts_on_login(&state.db, user.id, &sid).await?;
// }

3 lock decision cart consolidation:

  • Merge rule MANDATORY quantity SUM — KHÔNG max, KHÔNG overwrite, KHÔNG keep-guest, KHÔNG keep-user. Lý do UX: user thêm 2 sản phẩm X vào guest cart trên điện thoại, sau đó login phát hiện account đã có 1 sản phẩm X trong user cart trước đó từ laptop → kỳ vọng cart cuối có 3 sản phẩm X (sum) chứ không phải max(2,1) = 2 (mất 1) hoặc overwrite. Pattern này khớp Amazon/Shopify behavior industry standard.
  • Transaction wrap toàn bộ 4 step — đảm bảo atomic: nếu Step 3 hoặc Step 4 fail → rollback giữ nguyên cả 2 cart, user retry login lại không corrupt data; nếu commit OK thì cả guest cart đã bị xóa và user cart đã có toàn bộ items merge.
  • Delete guest cart CASCADE tận dụng FK cart_id REFERENCES carts ON DELETE CASCADE step 3 — chỉ DELETE 1 row carts, Postgres auto-clean cart_items, không cần script clean thủ công.
8

Verify End-To-End Workflow

Bootstrap migration + server:

cargo sqlx migrate run --source crates/shop-db/migrations
# Applied 20260616110000/migrate create carts (migration 12)

AUTO_MIGRATE=true cargo run -p shop-api
# shop-api listening on 0.0.0.0:3000

Test 1 — Add product vào cart:

curl -X POST http://localhost:3000/api/v1/cart/items \
  -H 'Content-Type: application/json' \
  -d '{"product_id": 1, "quantity": 2}'

# 200 OK
# {
#   "id": 1,
#   "items": [{
#     "id": 1, "product_id": 1, "product_name": "iPhone 15",
#     "product_slug": "iphone-15", "unit_price": "25000000.00",
#     "quantity": 2, "subtotal": "50000000.00",
#     "available_stock": 10, "is_available": true,
#     "added_at": "..."
#   }],
#   "subtotal": "50000000.00",
#   "item_count": 2,
#   "expires_at": "2026-07-16T..."
# }

Test 2 — Add cùng product → UPSERT increment:

curl -X POST http://localhost:3000/api/v1/cart/items \
  -H 'Content-Type: application/json' \
  -d '{"product_id": 1, "quantity": 1}'

# quantity now 3 (= 2 cũ + 1 mới), KHÔNG tạo row mới
# Verify DB: SELECT COUNT(*) FROM cart_items WHERE cart_id = 1; → 1

Test 3 — Update quantity absolute:

curl -X PATCH http://localhost:3000/api/v1/cart/items/1 \
  -H 'Content-Type: application/json' \
  -d '{"quantity": 5}'

# 200 OK, quantity = 5 (replace, KHÔNG cộng dồn)

Test 4 — Stock insufficient warning (is_available = false):

# Admin restock product 1 còn 2 (giả lập out of stock)
# Sau đó GET cart vẫn quantity 5
curl http://localhost:3000/api/v1/cart

# 200 OK
# items: [{
#   ..., "quantity": 5, "available_stock": 2,
#   "is_available": false
# }]
# Client UI render warning "Còn 2 sản phẩm, vui lòng giảm số lượng"

Test 5 — Checkout success:

# Adjust quantity về 2 trước
curl -X PATCH http://localhost:3000/api/v1/cart/items/1 \
  -d '{"quantity": 2}'

curl -X POST http://localhost:3000/api/v1/cart/checkout \
  -H 'Content-Type: application/json' \
  -H "Idempotency-Key: $(uuidgen)" \
  -d '{
    "payment_method": {"type": "cod", "phone": "+84912345678"},
    "note": "Giao sáng sớm"
  }'

# 201 Created
# Location: /api/v1/orders/1
# Body OrderResponseDto đầy đủ với items + payment

# Verify cart đã clear:
curl http://localhost:3000/api/v1/cart
# { items: [], item_count: 0, subtotal: "0.00" }

Test 6 — Checkout fail khi stock insufficient:

curl -X POST http://localhost:3000/api/v1/cart/items \
  -d '{"product_id": 1, "quantity": 999}'
# 200 OK is_available: false (warning)

curl -X POST http://localhost:3000/api/v1/cart/checkout \
  -H "Idempotency-Key: $(uuidgen)" \
  -d '{"payment_method": {"type": "cod", "phone": "+84912345678"}}'

# 422 Unprocessable Entity
# { "error": "product iphone-15 insufficient stock: requested 999, available 2",
#   "code": "VALIDATION_FAILED", "request_id": "..." }
# Cart giữ nguyên (chưa clear)

3 trường hợp edge case quan trọng đã cover: (a) UPSERT pattern dup product → increment quantity đúng; (b) is_available flag set false khi stock < quantity guard checkout fail-fast; (c) Idempotency middleware chặn double checkout cùng key.

9

Tổng Kết

  • 2 model cart: server-side persistent (Shop API lock B69) vs session-only client (alternative bị loại do không cross-device sync).
  • DB-backed cart B69 + Redis migration G15 preview (TTL 30 ngày Redis-managed, latency <1ms).
  • Migration 12 create_carts: carts + cart_items với CHECK constraint (user_id OR session_id).
  • UNIQUE WHERE NOT NULL partial index pattern lock — 1 cart/user + 1 cart/guest session, tránh false conflict NULL = NULL.
  • UNIQUE (cart_id, product_id) cart_items lock — UPSERT pattern thay duplicate row, mỗi product 1 dòng cart.
  • ON CONFLICT DO UPDATE UPSERT lock pattern cho add item — quantity SUM (= cũ + mới) atomic 1 statement.
  • 5 endpoint cart: GET /cart, POST /cart/items, PATCH /cart/items/{id}, DELETE /cart/items/{id}, DELETE /cart.
  • is_available flag client UI render warning khi available_stock < quantity (admin restock giảm sau khi user đã add cart).
  • Checkout flow 6 step: get cart → verify stock → convert items → create_order_atomic (with_retry) → clear cart → response 201 + Location.
  • Cart KHÔNG decrement stock — chỉ check available; decrement tại create_order_atomic B54 (FOR UPDATE pessimistic row lock) tránh ghost reservation.
  • Cart consolidation merge guest cart → user cart khi login (B112) — rule lock MANDATORY quantity SUM (KHÔNG max, KHÔNG overwrite).
  • CASCADE FK defense in depth: carts → cart_items + products → cart_items; delete cart auto-clean items.
  • expires_at 30 ngày auto-cleanup cron G18 deploy (DELETE FROM carts WHERE expires_at < NOW()).
  • Endpoint singular /cart ngoại lệ B61 plural rule — 1 user 1 cart semantic chính xác, sub-resource /cart/items plural đúng quy tắc.
  • Idempotency middleware MANDATORY cho POST /cart/checkout (lock B66 continued cho mọi mutation financial-impact).
  • KHÔNG audit_logs cho cart (lock B65 continued — ephemeral data, lifecycle 30 ngày).
  • File path lock B69: NEW crates/shop-db/migrations/20260616110000_create_carts.sql + NEW crates/shop-db/src/carts.rs + NEW crates/shop-common/src/dto/cart.rs + NEW crates/shop-api/src/routes/cart.rs + UPDATED shop-db/src/lib.rs + UPDATED shop-common/src/dto/mod.rs + UPDATED shop-api/src/routes/mod.rs + UPDATED shop-api/src/router.rs.
10

Bài Tập Củng Cố

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

  1. 2 model cart server-side persistent vs session-only client — pros/cons mỗi cách? Shop API chọn server-side và lý do cụ thể nào?
  2. UPSERT ON CONFLICT DO UPDATE add item — tại sao thay simple INSERT 2-statement (SELECT check exist + INSERT/UPDATE)? Cho scenario race condition + UX duplicate cụ thể.
  3. is_available flag client UI — workflow render warning. Cho ví dụ cụ thể stock = 2, quantity = 5 → UI behavior + lý do guard checkout fail-fast.
  4. Checkout flow 6 step — bước nào MANDATORY transaction wrap? Tại sao clear cart SAU order create thay TRƯỚC? Cho scenario fail giữa 2 step.
  5. Cart consolidation merge guest → user — rule quantity SUM vs max vs overwrite. UX rule nào correct và lý do cụ thể?
Đáp án
  1. 2 model cart server vs client + lý do Shop API chọn server: Server-side persistent lưu cart trên backend (DB hoặc Redis) định danh qua user_id hoặc session_id. Pros: (a) cross-device sync — user thêm cart trên điện thoại, mở laptop thấy ngay; (b) không lose khi clear browser cache; (c) analytics-friendly — backend track cart abandonment rate cho remarketing email (Klaviyo/Mailchimp); (d) stock validation chính xác — backend single source of truth không tin client; (e) admin support — CS xem cart user đang gặp vấn đề. Cons: (i) cần auth hoặc session cookie cho guest; (ii) storage cost user × items; (iii) latency thêm round-trip API. Session-only client lưu cart localStorage/cookie. Pros: (a) không cần auth — guest browse anonymously; (b) simple — backend chỉ expose product API; (c) latency 0 localStorage 1ms. Cons: (i) lose khi clear browser cache, reset device, đổi trình duyệt; (ii) không sync cross-device — mỗi thiết bị 1 cart riêng UX gãy; (iii) storage cap 5-10MB origin localStorage; (iv) không validate — client tự tính price/stock dễ tampering qua devtools (attacker set price = 1đ thanh toán hàng triệu). Shop API lock server-side persistent DB-backed (PostgreSQL) B69 — 4 lý do MANDATORY: (1) cross-device sync bắt buộc e-commerce hiện đại — Google Mobile Path-to-Purchase 2024 60-70% checkout xảy ra trên thiết bị KHÁC với browse, mất cart = mất conversion; (2) remarketing email abandoned cart bắt buộc dữ liệu backend — workflow gửi mail sau 24h bỏ rơi cart (G24 notification job) tăng conversion 10-30% theo Baymard Institute; (3) stock validation backend single source of truth — tránh client cache stock cũ cho phép user thêm vượt available rồi checkout fail UX kém; (4) Redis migration G15 sẵn sàng — pattern DB-backed B69 abstract qua module shop-db::carts, G15 swap implementation sang shop-cache::carts dùng Redis hash key cart:<user_id> với TTL 30 ngày Redis-managed (Project Spec G6 lock). Schema PostgreSQL B69 phù hợp dev/test pattern đầy đủ trước khi optimize latency với Redis. Generalize industry: Amazon + Shopify + WooCommerce + Magento đều dùng server-side persistent; pure client-side cart chỉ phù hợp landing page micro-site bán 1-2 SKU đơn giản KHÔNG cần cross-device.
  2. UPSERT vs INSERT 2-statement + race condition: Approach naïve 2 statement: SELECT id FROM cart_items WHERE cart_id = $1 AND product_id = $2 check exist; nếu exist UPDATE quantity = quantity + $3 else INSERT. Scenario race condition cụ thể: 2 request A + B song song cùng user cùng product trên 2 device (vd user double-tap nút "Add to cart" trên mobile mạng chậm). (T0) Request A SELECT → row không tồn tại; (T0+1ms) Request B SELECT → row vẫn không tồn tại (Postgres Read Committed isolation MVCC snapshot); (T0+5ms) Request A INSERT → tạo row id=1 quantity=1 commit; (T0+10ms) Request B INSERT → UNIQUE constraint violation 23505 vì cart_items_cart_product_unique_idx đã có row với (cart_id, product_id) giống — backend trả 409 Conflict cho user B → UX broken "Lỗi không xác định, vui lòng thử lại". Nếu user retry → request thứ 3 SELECT → row có rồi → UPDATE quantity → OK; nhưng 1 click bị mất → kỳ vọng quantity = 2 nhưng thực tế = 1. UX duplicate cụ thể không có UNIQUE: nếu schema KHÔNG có UNIQUE (cart_id, product_id) thì cả 2 INSERT đều succeed → cart có 2 row cùng product → UI render "iPhone × 1" + "iPhone × 1" thay vì "iPhone × 2", user confused click xóa 1 dòng vẫn còn 1 dòng. Approach UPSERT ON CONFLICT DO UPDATE 1 statement: INSERT ... ON CONFLICT (cart_id, product_id) DO UPDATE SET quantity = cart_items.quantity + EXCLUDED.quantity. Postgres engine xử lý atomic: nếu INSERT conflict UNIQUE → tự động UPDATE row có sẵn cộng EXCLUDED.quantity (giá trị từ row INSERT bị reject); 1 statement = 1 lock = không có race window. Request A + B song song đều succeed: A insert → quantity=1; B conflict → UPDATE += 1 → quantity=2 atomic. Pros UPSERT: (a) atomic không race; (b) 1 round-trip DB thay 2; (c) ít code Rust hơn; (d) UX consistent dù mạng chậm/double-tap. Cons UPSERT: (a) ON CONFLICT yêu cầu UNIQUE constraint tồn tại — schema phải design trước; (b) DO UPDATE syntax phụ thuộc Postgres (MySQL có INSERT ... ON DUPLICATE KEY UPDATE khác cú pháp); (c) RETURNING không phân biệt được insert mới vs update — cần thêm RETURNING (xmax = 0) AS inserted trick nếu cần. Lock Shop API B69 vĩnh viễn: UPSERT pattern MANDATORY cho mọi entity có UNIQUE constraint composite + UX add-quantity-style (cart_items B69, cart_items merge B69 step 3 + step 7, future wishlist_items, notification_preferences merge). Generalize: nguyên tắc "atomic state transition at DB layer" rộng rãi PostgreSQL + SQLite + MariaDB hỗ trợ UPSERT; MongoDB updateOne với upsert: true; Redis HINCRBY tương đương atomic increment.
  3. is_available flag + workflow render warning + guard checkout: Scenario cụ thể stock 2, quantity 5: (T0) admin set products.stock = 10; (T0+1h) user thêm vào cart 5 sản phẩm — backend ghi cart_items.quantity = 5, KHÔNG decrement stock (lock B69 pattern), trả available_stock: 10, quantity: 5, is_available: true; (T0+2h) admin update stock = 2 (vd điều chỉnh kiểm kê thực tế, hàng hỏng); (T0+3h) user mở cart GET /cart → backend JOIN products lấy stock = 2 mới nhất, compute is_available = (2 >= 5) = false → trả response với flag. Client UI behavior: (a) render item iPhone với badge cảnh báo màu đỏ "Chỉ còn 2 trong kho, vui lòng giảm số lượng"; (b) disable nút "Thanh toán" toàn cart hoặc disable per-item; (c) hiển thị slider quantity với max = 2 thay 100; (d) optionally auto-suggest reduce quantity về available_stock với 1 click. Lý do guard checkout fail-fast: nếu KHÔNG có flag và user click "Thanh toán" → backend Step 2 verify stock fail trả 422 "insufficient stock" → user nhận lỗi tại trang checkout sau khi đã nhập địa chỉ + chọn payment method → UX gãy 3 step ngược lại; có flag → cảnh báo ngay tại trang cart, user adjust quantity trước khi enter checkout funnel → UX mượt. Defense in depth 2 lớp: (i) UI flag warn sớm — chặn 95% case "stock decrement giữa giờ user browse"; (ii) Backend Step 2 verify tại checkout — chặn 5% case còn lại race condition cuối cùng (user A + B đồng thời checkout cùng 1 stock unit). Cách compute is_available: backend tính tại DTO transform `is_available: i.available_stock >= i.quantity` từ JOIN products.stock — luôn fresh tại query time, KHÔNG cache; client KHÔNG tự tính (tránh tampering). Lock Shop API B69 vĩnh viễn: pattern flag warning client-render áp dụng cho mọi resource có "stock-like" constraint: cart_items B69, future product variants stock B85, ticket booking seat availability G24, hotel room inventory G27. Generalize industry: Booking.com hiển thị "Còn 2 phòng giá này — đặt nhanh!" + Amazon "Only 3 left in stock!" — pattern psychological scarcity + technical warning kết hợp.
  4. Checkout flow 6 step + transaction wrap + clear cart timing: 6 step recap: (1) get cart + items; (2) verify stock available pre-flight; (3) convert cart items → order items; (4) create_order_atomic; (5) clear cart; (6) build response. Step MANDATORY transaction wrap: Step 4 create_order_atomic B54 — đã wrap transaction nội bộ với 5 sub-step (FOR UPDATE row lock products + INSERT orders + INSERT order_items + UPDATE products.stock decrement + INSERT payments); pessimistic row lock guarantee atomic giữa check stock và decrement, race condition 2 user mua cùng 1 stock unit cuối được handle đúng — 1 succeed 1 fail 422. Step 1-3 không cần transaction (chỉ SELECT đọc); Step 5-6 không cần transaction nhưng phải handle ordering. Tại sao clear cart SAU order create (Step 5 sau Step 4) thay TRƯỚC: Scenario clear TRƯỚC: clear cart Step 4 → create_order_atomic Step 5 fail (vd Postgres deadlock 40P01 hoặc network timeout) → user mất cả cart (đã clear) + chưa có order → UX gãy nặng user phải re-add toàn bộ items từ đầu, có khi quên mua. Scenario clear SAU (B69 lock): create_order_atomic Step 4 fail → cart vẫn còn nguyên → return 422/500 → user retry checkout không mất items. Nếu Step 4 succeed nhưng Step 5 fail (rare, vd network glitch sau commit order) → user có order valid + cart vẫn còn → 2 outcome safe: (a) cron job G18 cleanup expired cart sẽ tự xóa sau 30 ngày; (b) user mở app thấy cart còn nguyên (UX hơi confusing 1 lần) → gọi DELETE /cart thủ công hoặc UI auto-detect "cart contains items already ordered" suggest clear. Idempotent retry safe: nếu user retry checkout với cùng Idempotency-Key → middleware B66 phát hiện cached response → trả lại 201 đã cached → KHÔNG tạo order thứ 2 + KHÔNG clear cart lần 2 (cart đã clear lần đầu). Generalize "do critical thing first, cleanup later": nguyên tắc Saga pattern microservices — payment service charge trước rồi mới ship; reservation service confirm trước rồi mới release inventory; pattern lock cho mọi multi-step transaction Shop API tương lai (payment B71, refund G21, shipment G23). Alternative 2PC (Two-Phase Commit) distributed transaction phức tạp + performance kém, Shop API monolith chưa cần; chỉ nâng cấp khi tách microservice G30+.
  5. Cart consolidation merge guest → user + rule SUM vs max vs overwrite: Scenario cụ thể: user A lướt anonymously ngày 1 thêm iPhone × 2 vào guest cart (session_id cookie); ngày 2 user A login (đã có account từ trước) phát hiện account đã có iPhone × 1 từ thiết bị laptop trước đó. 3 rule khả thi merge: (a) quantity SUM = 2 + 1 = 3; (b) quantity MAX = max(2, 1) = 2; (c) overwrite = giữ 1 trong 2 cart hoàn toàn (guest hoặc user). SUM (B69 lock MANDATORY): matches user mental model "tôi đã thêm 2 cái trên điện thoại + 1 cái trên laptop, tổng 3 cái" — Amazon + Shopify + WooCommerce industry standard. MAX: matches scenario hiếm "user nghĩ guest cart đang ghi đè previous user cart không cộng dồn" — nhưng UX confusing vì mất 1 cái (user 1 + guest 2 = guest 2, mất user 1). Overwrite keep-guest: rủi ro user mất history user cart cũ; overwrite keep-user: rủi ro user mất công thêm vào guest cart sáng nay. Tại sao SUM correct: (i) Principle of least surprise — user vừa thêm vào guest cart 5 phút trước, login → kỳ vọng items vẫn còn đó cộng với items cũ; (ii) Conversion-friendly — không bao giờ "mất" items, giảm cart abandonment rate; (iii) Recoverable — nếu user thực sự muốn xóa duplicate, gọi PATCH quantity hoặc DELETE item dễ; ngược lại nếu mất items thì khó re-add (user phải nhớ đã thêm gì); (iv) Industry consensus — 80%+ e-commerce theo pattern này theo Baymard Institute research. UX rule lock B69 vĩnh viễn quantity SUM cho mọi cart consolidation Shop API tương lai (B112 login merge + future device-switch merge). Edge case stock cap: sau SUM nếu quantity vượt available_stock thì is_available = false → user thấy warning + adjust quantity trước checkout — handle qua flag lock Bước 5; KHÔNG cap quantity tại merge để giữ semantic "items user đã chọn", chỉ cap tại UI render. Edge case quantity validate max 100: AddCartItemDto validate range 1-100 tại API boundary, nhưng merge bypass DTO validation vì là DB-internal operation; nếu SUM vượt 100 (vd guest 60 + user 50 = 110) → DB constraint CHECK (quantity > 0) không cap upper bound, item vẫn save; warning UI handle qua available_stock check. Pattern transaction wrap MANDATORY 4 step (find guest cart + UPSERT user cart + INSERT items SUM + DELETE guest cart) atomic; nếu Step 3 hoặc 4 fail → rollback giữ nguyên cả 2 cart, user retry login lại không corrupt. Generalize "merge with cumulative semantic": pattern lock cho mọi user data consolidation Shop API (cart B69 + wishlist B105 + notification preferences G24 + saved addresses B112).
11

Bài Tiếp Theo

— implement POST /api/v1/users/register, GET /api/v1/users/me, PATCH /api/v1/users/me; password hash với argon2id (PHC string output); email verification preview (sinh token UUID v4 + send mail SMTP/Resend); áp dụng Shop API user registration flow theo Project Spec lock; foundation cho B112 (JWT auth + login + cart merge B69).