Mục lục
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_itemsbridge — 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/checkoutreusecreate_order_atomicB54 + 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_itemsvớ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 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 sangshop-cache::cartsdùng Redis keycart:<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).
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 constraintuser_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_idxchỉ index row cóuser_id NOT NULLđảm bảo 1 cart per user;carts_session_unique_idxtươ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_atdefault 30 ngày — TTL lock B69 vĩnh viễn theo Project Spec; cron job G18 chạy hàng đêmDELETE 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ộngupdate_updated_at_column()B62 thêm logicNEW.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 patternON CONFLICT DO UPDATEtă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_atreuseupdate_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
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).
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/itemsgiữ plural đúng quy tắc cho collection. is_availableflag client UI render — khiavailable_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.
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_atomicB54 vớiFOR UPDATErow 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 /cartthủ 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_retrySerializationFailure (B55 lock continued) — wrapcreate_order_atomicretry 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; alternativePOST /api/v1/checkouttop-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_responseB66 — handler thin pattern 30-50 dòng/endpoint lock; trả fullOrderResponseDtovới items denormalize + payment.payload_safe sanitized PII; client redirect sang trang order detail dùngLocationheader.
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 CASCADEstep 3 — chỉ DELETE 1 rowcarts, Postgres auto-clean cart_items, không cần script clean thủ công.
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.
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_itemsvớ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 UPDATEUPSERT 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_availableflag client UI render warning khiavailable_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_at30 ngày auto-cleanup cron G18 deploy (DELETE FROM carts WHERE expires_at < NOW()).- Endpoint singular
/cartngoại lệ B61 plural rule — 1 user 1 cart semantic chính xác, sub-resource/cart/itemsplural đú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+ NEWcrates/shop-db/src/carts.rs+ NEWcrates/shop-common/src/dto/cart.rs+ NEWcrates/shop-api/src/routes/cart.rs+ UPDATEDshop-db/src/lib.rs+ UPDATEDshop-common/src/dto/mod.rs+ UPDATEDshop-api/src/routes/mod.rs+ UPDATEDshop-api/src/router.rs.
Bài Tập Củng Cố
Tự trả lời, đáp án ở cuối:
- 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?
- UPSERT
ON CONFLICT DO UPDATEadd item — tại sao thay simple INSERT 2-statement (SELECT check exist + INSERT/UPDATE)? Cho scenario race condition + UX duplicate cụ thể. is_availableflag client UI — workflow render warning. Cho ví dụ cụ thể stock = 2, quantity = 5 → UI behavior + lý do guard checkout fail-fast.- 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.
- Cart consolidation merge guest → user — rule quantity SUM vs max vs overwrite. UX rule nào correct và lý do cụ thể?
Đáp án
- 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.
- UPSERT vs INSERT 2-statement + race condition: Approach naïve 2 statement:
SELECT id FROM cart_items WHERE cart_id = $1 AND product_id = $2check exist; nếu existUPDATE quantity = quantity + $3elseINSERT. 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 UPSERTON CONFLICT DO UPDATE1 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 UPDATEkhác cú pháp); (c) RETURNING không phân biệt được insert mới vs update — cần thêmRETURNING (xmax = 0) AS insertedtrick 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; MongoDBupdateOne với upsert: true; RedisHINCRBYtương đương atomic increment. is_availableflag + workflow render warning + guard checkout: Scenario cụ thể stock 2, quantity 5: (T0) admin setproducts.stock = 10; (T0+1h) user thêm vào cart 5 sản phẩm — backend ghicart_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ấystock = 2mới nhất, computeis_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.- 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+.
- 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).
Bài Tiếp Theo
Bài 70: Users Register + Profile Endpoint — 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).
