Mục lục
Mục Tiêu Bài Học
Sau bài học, bạn sẽ:
- Implement
POST /api/v1/ordersendpoint dùngcreate_order_atomic(B54) qua HTTP handler mỏng. - Implement Idempotency-Key middleware Stripe-style cache 24h DB-backed (preview Redis G19 production scale).
- Áp dụng
with_retryhelper choSerializationFailuretransient (B55) — max 3 attempts backoff exponential 10/20/40ms. - Define
CreateOrderDto+CreateOrderItemDto+OrderResponseDto+OrderItemResponseDto+PaymentResponseDto— 5 DTO mớicrates/shop-common/src/dto/order.rs. - Migration
idempotency_keystable server-side cache với SHA256 request_hash verify same body Stripe rule. - Verify end-to-end 5 test: success + insufficient stock + replay same key + mismatch body + verify stock decrement.
- Foundation cho B71 Stripe payment integration + B72 webhook + G15 Redis cache migration.
CreateOrderDto Schema
B54 đã lock service layer create_order_atomic nhận args primitive (user_id: i64, items: Vec<CreateOrderItem>, payment_type: &str, payment_payload: JsonValue). HTTP handler cần một lớp DTO riêng ở shop-common phục vụ wire format JSON + validator B41. Tạo mới module order trong dto/:
// File: crates/shop-common/src/dto/order.rs
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use validator::Validate;
use super::{Money, OrderId, PaymentMethod, ProductId, UserId};
#[derive(Debug, Clone, Deserialize, Validate)]
pub struct CreateOrderDto {
#[validate(length(min = 1, max = 50, message = "1-50 item mỗi order"))]
#[validate(nested)]
pub items: Vec<CreateOrderItemDto>,
pub payment_method: PaymentMethod, // B43 internally tagged
#[serde(default)]
pub note: Option<String>,
}
#[derive(Debug, Clone, Deserialize, Validate)]
pub struct CreateOrderItemDto {
pub product_id: ProductId,
#[validate(range(min = 1, max = 1000, message = "quantity 1-1000"))]
pub quantity: u32,
}
3 lock decision DTO request:
itemslength 1-50 — 1 đảm bảo không tạo đơn rỗng (nghiệp vụ thực tế client phải có ít nhất 1 sản phẩm), 50 cap chống abuse + giữ payload < ~5KB an toàn proxy/gateway. Cap khớp cap B65 bulk_restore tương đương — Shop API quy ước batch sync 50-100 item per request.payment_method: PaymentMethodreuse enum internally tagged đã lock B43 (Stripe/BankTransfer/Cod) — wire format flat{"type": "cod", "phone": "+84..."}; mọi validate per-variant đã có sẵn (PHONE_REGEX + ACCOUNT_NUMBER_REGEX cross-DTO B43).note: Option<String>với#[serde(default)]— field tùy chọn cho note ship ("để hộp ở quầy lễ tân"), không bắt buộc, default None khi client bỏ qua.
5 DTO response định nghĩa wire format trả client sau khi tạo đơn thành công:
// File: crates/shop-common/src/dto/order.rs (tiếp)
#[derive(Debug, Clone, Serialize)]
pub struct OrderResponseDto {
pub id: OrderId,
pub user_id: UserId,
pub total: Money,
pub status: String,
pub items: Vec<OrderItemResponseDto>,
pub payment: PaymentResponseDto,
#[serde(skip_serializing_if = "Option::is_none")]
pub note: Option<String>,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
}
#[derive(Debug, Clone, Serialize)]
pub struct OrderItemResponseDto {
pub product_id: ProductId,
pub product_name: String,
pub product_slug: String,
pub quantity: u32,
pub unit_price: Money,
pub subtotal: Money,
}
#[derive(Debug, Clone, Serialize)]
pub struct PaymentResponseDto {
pub payment_type: String,
pub status: String,
pub payload_safe: serde_json::Value, // payload đã sanitize PII
}
Cập nhật dto/mod.rs re-export 5 DTO mới:
// File: crates/shop-common/src/dto/mod.rs (extend)
pub mod order;
pub use order::{
CreateOrderDto, CreateOrderItemDto, OrderResponseDto,
OrderItemResponseDto, PaymentResponseDto,
};
Lock convention DTO response: product_name + product_slug nhúng trực tiếp vào OrderItemResponseDto (denormalize) — client UI render order detail KHÔNG phải gọi thêm GET /products/:slug N+1 lần per item. subtotal: Money tính bên handler (unit_price × quantity) gửi sẵn cho client, tránh client tự nhân Decimal phía JavaScript có thể lose precision. payload_safe JSONB đã đi qua hàm sanitize ở bước 5.
Migration idempotency_keys Table
Idempotency-Key (khóa idempotent) là pattern Stripe đề xuất từ 2015 cho mọi mutation endpoint có thể bị retry — client gửi 1 UUID v4 random trong header Idempotency-Key, server cache response 24h, mọi request lặp với cùng key trả lại response cũ KHÔNG tái thực hiện logic. Pattern bảo vệ double-charge khi mạng client chập chờn (HTTP request gửi đi → server xử lý thành công → mạng đứt trước khi nhận 201 → client tự retry → KHÔNG cần đơn thứ 2 trùng).
Tạo migration thứ 10 (sau 9 migration B65 đã có):
sqlx migrate add --source crates/shop-db/migrations create_idempotency_keys
-- File: crates/shop-db/migrations/20260615190000_create_idempotency_keys.sql
CREATE TABLE idempotency_keys (
key TEXT PRIMARY KEY,
user_id BIGINT NOT NULL,
endpoint TEXT NOT NULL, -- "POST /api/v1/orders"
request_hash TEXT NOT NULL, -- SHA256 body verify same request
response_status SMALLINT, -- HTTP status code cached
response_body JSONB, -- response body cached
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
expires_at TIMESTAMPTZ NOT NULL
DEFAULT (NOW() + INTERVAL '24 hours')
);
CREATE INDEX idempotency_keys_expires_idx
ON idempotency_keys(expires_at);
CREATE INDEX idempotency_keys_user_endpoint_idx
ON idempotency_keys(user_id, endpoint, created_at DESC);
7 column + 2 index — mỗi quyết định mang ý nghĩa cụ thể:
key TEXT PRIMARY KEY— UUID v4 client-generated (Stripe convention). PRIMARY KEY ngầm tạo UNIQUE constraint + B-tree index → lookup O(log n). KHÔNG dùngUUIDtype Postgres vì client gửi raw string qua HTTP header, lưu TEXT đơn giản; trade-off ~16 byte phụ với UUID type không đáng cho table cache 24h.user_id BIGINT— scope cache per-user: hai user vô tình trùng UUID → KHÔNG conflict (mỗi user query vớiWHERE key = $1 AND user_id = $2). Lock decision: B66 dùnguser_id = 0placeholder cho đến khi B112 implement JWT extractor, sau đó injectuser_idthật từ claims.endpoint TEXT— distinguish cùng key dùng cho 2 endpoint khác nhau (POST /ordersvsPOST /payments) — Stripe rule, tránh confusion semantic giữa các resource.request_hash TEXTSHA256 body — Stripe verify same request: nếu client gửi cùng key nhưng body khác → 422 reject thay trả response cũ (tránh client gửi nhầm key reuse cross-request).response_status SMALLINT+response_body JSONB— cache HTTP status code + envelope JSON; replay trả nguyên trạng.expires_at TIMESTAMPTZ DEFAULT NOW() + INTERVAL '24 hours'— TTL 24h Stripe standard, đủ cho mọi retry kịch bản client (mobile background, mạng kém, user F5 page); cron job G19 sẽDELETE WHERE expires_at < NOW()hàng đêm clean up.- 2 index:
expires_atphục vụ cleanup nhanh,(user_id, endpoint, created_at DESC)composite phục vụ admin debug "user X gửi key gì gần đây".
Lock decision B66: DB-backed cache (KHÔNG Redis ở B66) — đơn giản infrastructure, dùng connection pool sẵn có, đủ scale cho early-stage Shop API. G19 production scale chuyển sang Redis idem:<key> namespace lock B43+B66 (TTL Redis tự manage, latency <1ms thay 5-10ms Postgres). Workspace dep thêm sha2 = "0.10" cho SHA256 hashing — pure-Rust impl, không kéo OpenSSL, ~50KB binary size tăng.
Apply migration:
sqlx migrate run --source crates/shop-db/migrations
# Hoặc khởi động shop-api với AUTO_MIGRATE=true (B52 lock)
AUTO_MIGRATE=true cargo run -p shop-api -- serve
Idempotency-Key Middleware
Middleware nằm ở crates/shop-api/src/middleware/idempotency.rs, áp selective per-route chỉ cho POST /orders + POST /payments (Stripe pattern KHÔNG global vì GET không có hiệu ứng phụ + DELETE bản chất idempotent HTTP method semantic).
// File: crates/shop-api/src/middleware/idempotency.rs
use axum::{
body::{Body, Bytes},
extract::{Request, State},
http::StatusCode,
middleware::Next,
response::{IntoResponse, Response},
Json,
};
use serde_json::Value;
use sha2::{Digest, Sha256};
use crate::state::AppState;
pub async fn idempotency_middleware(
State(state): State<AppState>,
request: Request,
next: Next,
) -> Response {
// Step 1: chỉ apply cho method có hiệu ứng phụ
if !matches!(request.method().as_str(), "POST" | "PUT") {
return next.run(request).await;
}
// Step 2: đọc Idempotency-Key header — không có thì pass-through
let key = match request.headers().get("Idempotency-Key")
.and_then(|v| v.to_str().ok())
.map(String::from)
{
Some(k) => k,
None => return next.run(request).await,
};
// Step 3: buffer body để hash + replay
let (parts, body) = request.into_parts();
let body_bytes = match axum::body::to_bytes(body, 10 * 1024 * 1024).await {
Ok(b) => b,
Err(_) => return (
StatusCode::PAYLOAD_TOO_LARGE,
"body too large",
).into_response(),
};
let request_hash = {
let mut hasher = Sha256::new();
hasher.update(&body_bytes);
format!("{:x}", hasher.finalize())
};
// Step 4: lookup cache (user_id placeholder, B112 inject từ JWT claims)
let user_id = 0i64;
let endpoint = format!("{} {}", parts.method, parts.uri.path());
let cached = sqlx::query!(
r#"
SELECT request_hash, response_status, response_body
FROM idempotency_keys
WHERE key = $1 AND user_id = $2 AND expires_at > NOW()
"#,
key, user_id,
)
.fetch_optional(&state.db)
.await
.ok()
.flatten();
if let Some(row) = cached {
// Verify same request body — Stripe rule
if row.request_hash != request_hash {
return (
StatusCode::UNPROCESSABLE_ENTITY,
Json(serde_json::json!({
"error": "Idempotency-Key reused with different request body",
"code": "IDEMPOTENCY_KEY_MISMATCH",
})),
).into_response();
}
let status = row.response_status
.and_then(|s| StatusCode::from_u16(s as u16).ok())
.unwrap_or(StatusCode::OK);
let body = row.response_body.unwrap_or(Value::Null);
return (status, Json(body)).into_response();
}
// Step 5: rebuild request với body buffered, chạy handler tiếp
let new_request = Request::from_parts(parts, Body::from(body_bytes.clone()));
let response = next.run(new_request).await;
// Step 6: cache response chỉ 2xx + 4xx (KHÔNG 5xx — retry an toàn hơn)
let status = response.status();
if !(status.is_success() || status.is_client_error()) {
return response;
}
let (resp_parts, resp_body) = response.into_parts();
let resp_bytes = match axum::body::to_bytes(resp_body, 1024 * 1024).await {
Ok(b) => b,
Err(_) => return (
StatusCode::INTERNAL_SERVER_ERROR,
"response too large",
).into_response(),
};
let body_value: Value = serde_json::from_slice(&resp_bytes)
.unwrap_or(Value::Null);
// Step 7: INSERT ON CONFLICT DO NOTHING — idempotent insert
let _ = sqlx::query!(
r#"
INSERT INTO idempotency_keys
(key, user_id, endpoint, request_hash, response_status, response_body)
VALUES ($1, $2, $3, $4, $5, $6)
ON CONFLICT (key) DO NOTHING
"#,
key, user_id, endpoint, request_hash,
status.as_u16() as i16,
body_value,
)
.execute(&state.db)
.await;
Response::from_parts(resp_parts, Body::from(resp_bytes))
}
7 step pattern lock vĩnh viễn:
- Step 1: filter method
POST+PUT— GET/DELETE/HEAD/OPTIONS skip vì idempotent native HTTP semantic không cần overhead. - Step 2: client KHÔNG gửi header → middleware pass-through, không bắt buộc. Pattern Stripe ưu tiên opt-in: client chủ động gửi mới được protection, dev mode không gửi vẫn chạy bình thường.
- Step 3: buffer toàn body bytes (cap 10MB) — bắt buộc vì cần SHA256 + cần đẩy nguyên body tiếp xuống handler.
- Step 4: cache lookup
WHERE expires_at > NOW()— row hết hạn coi như không có. - Step 5: nếu có cache +
request_hashkhớp → trả response cũ; nếu hash khác → 422 IDEMPOTENCY_KEY_MISMATCH (Stripe rule). - Step 6: cache 2xx + 4xx KHÔNG 5xx — server error có thể transient (DB timeout, deploy rolling), retry sau cùng key vẫn an toàn re-execute logic. Nếu cache 500 → client retry sẽ vĩnh viễn nhận 500 sai semantic.
- Step 7:
INSERT ... ON CONFLICT (key) DO NOTHING— race condition 2 request đồng thời cùng key cùng user → 1 insert thành công + 1 bỏ qua (request thứ 2 đã chạy xong handler cùng lúc, response gửi ra OK, cache sau cùng coi như nháp).
Workspace deps thêm trong Cargo.toml root:
[workspace.dependencies]
# ... các dep B10-B65 đã có ...
sha2 = "0.10"
crates/shop-api/Cargo.toml consume sha2.workspace = true.
Update crates/shop-api/src/middleware/mod.rs:
// File: crates/shop-api/src/middleware/mod.rs
pub mod request_id;
pub mod error_enrich;
pub mod idempotency; // NEW B66
create_order Handler Với with_retry
Handler thin pattern lock — chỉ làm 4 việc: parse + validate DTO → convert sang input service → gọi service wrap with_retry → build response DTO. Mọi logic transaction nằm ở shop_db::orders::create_order_atomic đã viết B54:
// File: crates/shop-api/src/routes/orders.rs
use axum::extract::State;
use rust_decimal::Decimal;
use shop_common::dto::{
CreateOrderDto, Money, OrderId, OrderItemResponseDto,
OrderResponseDto, PaymentMethod, PaymentResponseDto, ProductId, UserId,
};
use shop_common::with_retry;
use shop_common::error::AppError;
use shop_db::orders::{self as db, CreateOrderItem, OrderRow};
use crate::extractors::ValidatedJson;
use crate::responses::Created;
use crate::state::AppState;
pub async fn create_order(
State(state): State<AppState>,
ValidatedJson(dto): ValidatedJson<CreateOrderDto>,
) -> Result<Created<OrderResponseDto>, AppError> {
// Convert DTO → service input
let items: Vec<CreateOrderItem> = dto.items.iter()
.map(|i| CreateOrderItem {
product_id: i.product_id.0 as i64,
quantity: i.quantity as i32,
})
.collect();
let payment_type = match &dto.payment_method {
PaymentMethod::Stripe { .. } => "stripe",
PaymentMethod::BankTransfer { .. } => "bank_transfer",
PaymentMethod::Cod { .. } => "cod",
}.to_string();
let payment_payload = serde_json::to_value(&dto.payment_method)
.map_err(|e| AppError::Internal(anyhow::anyhow!(e)))?;
let user_id = 1i64; // placeholder, B112 inject từ JWT claims
// Retry SerializationFailure (B55 continued) — max 3 attempts
let pool = state.db.clone();
let order = with_retry(|| {
let pool = pool.clone();
let items = items.clone();
let payload = payment_payload.clone();
let ptype = payment_type.clone();
Box::pin(async move {
db::create_order_atomic(&pool, user_id, items, &ptype, payload)
.await
.map_err(AppError::from)
})
}, 3).await?;
let response = build_order_response(&state.db, order, dto.note).await?;
Ok(Created {
location: format!("/api/v1/orders/{}", response.id.0),
data: response,
})
}
Helper build_order_response fetch items + payment + sanitize PII trước khi trả client:
// File: crates/shop-api/src/routes/orders.rs (tiếp)
async fn build_order_response(
pool: &sqlx::PgPool,
order: OrderRow,
note: Option<String>,
) -> Result<OrderResponseDto, AppError> {
// Fetch items kèm product name + slug (denormalize cho client)
let item_rows = sqlx::query!(
r#"
SELECT oi.product_id, oi.quantity, oi.unit_price,
p.name AS product_name, p.slug AS product_slug
FROM order_items oi
JOIN products p ON p.id = oi.product_id
WHERE oi.order_id = $1
ORDER BY oi.id
"#,
order.id,
)
.fetch_all(pool)
.await?;
let items: Vec<OrderItemResponseDto> = item_rows.into_iter().map(|r| {
let qty = r.quantity as u32;
OrderItemResponseDto {
product_id: ProductId(r.product_id as u64),
product_name: r.product_name,
product_slug: r.product_slug,
quantity: qty,
unit_price: Money(r.unit_price),
subtotal: Money(r.unit_price * Decimal::from(qty)),
}
}).collect();
// Fetch payment 1:1 với order
let payment_row = sqlx::query!(
r#"
SELECT payment_type, status, payment_payload
FROM payments
WHERE order_id = $1
"#,
order.id,
)
.fetch_one(pool)
.await?;
let payload_safe = sanitize_payment_payload(&payment_row.payment_payload);
Ok(OrderResponseDto {
id: OrderId(order.id as u64),
user_id: UserId(order.user_id as u64),
total: Money(order.total),
status: order.status,
items,
payment: PaymentResponseDto {
payment_type: payment_row.payment_type,
status: payment_row.status,
payload_safe,
},
note,
created_at: order.created_at,
updated_at: order.updated_at,
})
}
Helper sanitize_payment_payload mask PII trước khi expose ra response — giữ payment_intent_id (Stripe ID không nhạy cảm, debug cần) + mask account_number + phone last 4 digit:
// File: crates/shop-api/src/routes/orders.rs (tiếp)
use serde_json::Value;
fn sanitize_payment_payload(payload: &Value) -> Value {
let mut safe = payload.clone();
if let Some(obj) = safe.as_object_mut() {
// Mask account_number: "1234567890" → "***7890"
if let Some(Value::String(acc)) = obj.get_mut("account_number") {
let masked = mask_last4(acc);
*acc = masked;
}
// Mask phone: "+84912345678" → "***5678"
if let Some(Value::String(phone)) = obj.get_mut("phone") {
let masked = mask_last4(phone);
*phone = masked;
}
// payment_intent_id Stripe — giữ nguyên, KHÔNG sensitive
}
safe
}
fn mask_last4(s: &str) -> String {
let len = s.chars().count();
if len <= 4 {
return "***".to_string();
}
let tail: String = s.chars().skip(len - 4).collect();
format!("***{}", tail)
}
3 pattern lock vĩnh viễn ở handler:
with_retry(closure, 3)— B55 helper, chỉ retryAppError::SerializationFailure(SQLSTATE 40001/40P01 transient); mọi error khác bubble lên ngay. Closure returnBox::pin(async move { ... })vìwith_retrynhậnFnMut() -> Futtrait bound, đảm bảo gọi nhiều lần được.3làmax_attemptstổng cộng — backoff 10ms + 20ms + 40ms = max latency thêm 70ms, chấp nhận được cho UX (user thấy delay nhỏ tốt hơn thấy fail).build_order_responsetách hàm riêng — handler chính giữ thin ~30 dòng, logic build response phức tạp (2 query phụ + map + sanitize) đẩy hàm phụ; sau này B67 GET /orders detail dùng lại helper này.sanitize_payment_payloadMANDATORY trước khi expose payment payload — mask PII bằng pattern last 4 digit (chuẩn ngân hàng Việt Nam + Stripe Dashboard hiển thị "•••• 4242");payment_intent_idStripe KHÔNG mask vì là ID public, dev cần debug refund/dispute. Pattern reuse cho mọi response payment Shop API tương lai (B71 GET /orders/:id, B72 webhook callback, G15 admin/orders).
Wire Route + Apply Middleware Order-Specific
Tạo function routes() đóng gói sub-router orders, áp middleware selective chỉ cho POST /orders (KHÔNG cho POST /orders/{id}/cancel B65 vì cancel không có payment side-effect, KHÔNG global):
// File: crates/shop-api/src/routes/orders.rs (extend tổ chức router)
use axum::routing::post;
use axum::Router;
use axum::middleware::from_fn_with_state;
pub fn routes(state: AppState) -> Router<AppState> {
let create_order_route = Router::new()
.route("/orders", post(create_order))
.layer(from_fn_with_state(
state.clone(),
crate::middleware::idempotency::idempotency_middleware,
));
let other_routes = Router::new()
.route("/orders/{id}/cancel", post(cancel_order));
create_order_route.merge(other_routes)
}
Update master router gom orders sub-router:
// File: crates/shop-api/src/router.rs (extend)
use crate::routes;
pub fn build_router(state: AppState) -> Router {
let api_v1 = Router::new()
.merge(routes::products::routes())
.merge(routes::categories::routes())
.merge(routes::orders::routes(state.clone())) // ← B66 thêm
.merge(routes::admin::routes());
Router::new()
.route("/", get(root))
.merge(routes::health::routes())
.nest("/api/v1", api_v1)
// ... layer stack lock B50 + B39 không đổi
.with_state(state)
}
Lock decision Stripe pattern: middleware Idempotency-Key apply per-route selective chứ KHÔNG global toàn API. Lý do cụ thể:
- GET bản chất idempotent HTTP, cache trùng cùng response client KHÔNG cần overhead lookup DB.
- DELETE idempotent native — xóa lần 2 vẫn cùng kết quả ("đã xóa rồi" hoặc "không có gì để xóa") không gây side-effect tài chính.
- POST cancel_order B65 có state machine chống lặp (cancel cancelled → 422), không cần idempotency layer phụ.
- POST create_order + POST /payments (tương lai B71) là 2 endpoint tốn tiền — double-execute = double-charge / double-stock-decrement → buộc cần idempotency.
Stripe API hiện hành (2026) áp pattern selective y hệt: Idempotency-Key chỉ effective cho POST /v1/charges, POST /v1/payment_intents, POST /v1/refunds — endpoint GET/DELETE đều không trigger logic cache.
Verify End-To-End
Setup product seed trước khi test:
curl -X POST http://localhost:3000/api/v1/products \
-H 'Content-Type: application/json' \
-d '{"name":"iPhone 15","slug":"iphone-15","price":"25000000.00","stock":10}'
# → 201 Created, product_id = 1
Test 1 — Order hợp lệ + Idempotency-Key first time:
curl -i -X POST http://localhost:3000/api/v1/orders \
-H 'Content-Type: application/json' \
-H 'Idempotency-Key: 550e8400-e29b-41d4-a716-446655440001' \
-d '{
"items": [{"product_id": 1, "quantity": 2}],
"payment_method": {
"type": "cod",
"phone": "+84912345678"
},
"note": "Để hộp ở quầy lễ tân"
}'
# HTTP/1.1 201 Created
# Location: /api/v1/orders/1
# Content-Type: application/json; charset=utf-8
# {
# "id": 1,
# "user_id": 1,
# "total": "50000000.00",
# "status": "pending",
# "items": [
# {"product_id":1,"product_name":"iPhone 15","product_slug":"iphone-15",
# "quantity":2,"unit_price":"25000000.00","subtotal":"50000000.00"}
# ],
# "payment": {
# "payment_type": "cod",
# "status": "pending",
# "payload_safe": {"type":"cod","phone":"***5678"}
# },
# "note": "Để hộp ở quầy lễ tân",
# "created_at": "2026-06-15T10:00:00Z",
# "updated_at": "2026-06-15T10:00:00Z"
# }
Test 2 — Replay cùng Idempotency-Key + cùng body:
curl -i -X POST http://localhost:3000/api/v1/orders \
-H 'Content-Type: application/json' \
-H 'Idempotency-Key: 550e8400-e29b-41d4-a716-446655440001' \
-d '{
"items": [{"product_id": 1, "quantity": 2}],
"payment_method": {"type": "cod", "phone": "+84912345678"},
"note": "Để hộp ở quầy lễ tân"
}'
# 201 Created — return cached response, KHÔNG tạo order mới
# Verify count vẫn 1
psql shop -c "SELECT COUNT(*) FROM orders WHERE user_id = 1;"
# count: 1
Test 3 — Cùng Idempotency-Key + body khác:
curl -i -X POST http://localhost:3000/api/v1/orders \
-H 'Content-Type: application/json' \
-H 'Idempotency-Key: 550e8400-e29b-41d4-a716-446655440001' \
-d '{
"items": [{"product_id": 1, "quantity": 5}],
"payment_method": {"type": "cod", "phone": "+84912345678"}
}'
# HTTP/1.1 422 Unprocessable Entity
# {
# "error": "Idempotency-Key reused with different request body",
# "code": "IDEMPOTENCY_KEY_MISMATCH"
# }
Test 4 — Insufficient stock (quantity vượt stock):
curl -i -X POST http://localhost:3000/api/v1/orders \
-H 'Content-Type: application/json' \
-H 'Idempotency-Key: 550e8400-e29b-41d4-a716-446655440002' \
-d '{
"items": [{"product_id": 1, "quantity": 100}],
"payment_method": {"type": "cod", "phone": "+84912345678"}
}'
# HTTP/1.1 422 Unprocessable Entity
# {
# "error": "product 1 stock 8 < requested 100",
# "code": "VALIDATION_FAILED",
# "request_id": ""
# }
Test 5 — Verify stock decrement (sau Test 1 stock đã giảm từ 10 → 8):
curl http://localhost:3000/api/v1/products/iphone-15 | jq '.stock'
# 8
# Verify DB state đầy đủ
psql shop -c "SELECT id, stock FROM products WHERE slug = 'iphone-15';"
# id | stock
# ----+-------
# 1 | 8
psql shop -c "SELECT id, user_id, total, status FROM orders;"
# id | user_id | total | status
# ----+---------+--------------+---------
# 1 | 1 | 50000000.00 | pending
psql shop -c "SELECT key, endpoint, response_status FROM idempotency_keys;"
# key | endpoint | response_status
# --------------------------------------+--------------------+-----------------
# 550e8400-e29b-41d4-a716-446655440001 | POST /api/v1/orders| 201
# 550e8400-e29b-41d4-a716-446655440002 | POST /api/v1/orders| 422
Cả 4 row hợp lệ — cache chứa 1 row 201 success + 1 row 422 client error; KHÔNG có row nào status 5xx (lock decision Step 6 middleware). PII phone trong response đã masked "***5678" bảo vệ thông tin khách hàng.
Pattern Phân Tích — Layered Architecture
Sau B66, service layer pattern Shop API hoàn chỉnh 3 tầng. Mỗi tầng có trách nhiệm rõ ràng, không lấn nhau:
HTTP Handler (routes/orders.rs::create_order)
↓ ValidatedJson → DTO validate, Extension extract request_id
Service Function (shop-db/orders.rs::create_order_atomic)
↓ transaction-wrap, 5 step ATOMIC, OrderError domain
Repository / Query Layer (sqlx::query!)
↓ SQL prepared statement
PostgreSQL DB
Trách nhiệm handler ở create_order (~30 dòng):
- Parse + validate DTO qua
ValidatedJson(B41). - Convert DTO → service input type (
CreateOrderItem,&str payment_type,Value payload). - Wrap service call qua
with_retrymax 3 attempts. - Build response DTO + sanitize PII trước khi expose.
- Trả
Created<OrderResponseDto>với Location header (lock B40).
Trách nhiệm service ở create_order_atomic (~80 dòng B54):
- Mở transaction
pool.begin(). - Step 1: lock products
FOR UPDATE+ check stock. - Step 2-5: insert order + items + decrement stock + insert payment.
- Trả
OrderRow+OrderErrordomain enum. - KHÔNG biết HTTP, KHÔNG biết DTO wire format, KHÔNG biết JWT.
Trách nhiệm repository ở từng sqlx::query!:
- SQL prepared statement compile-time check (lock B53).
- Map DB row → struct domain (
OrderRow,ProductRow). - KHÔNG biết transaction boundary (caller quyết).
Lock pattern Shop API vĩnh viễn:
- Handler thin — 30-50 dòng/endpoint. Quá 50 dòng là tín hiệu logic business leak vào HTTP layer; refactor đẩy về service.
- Service fat — chứa transaction boundary, business rule, state machine, audit log.
- DTO transform tại handler boundary — service nhận type primitive (
i64,&str) hoặc struct riêng (CreateOrderItem), KHÔNG biết vềCreateOrderDtowire-format. - PII sanitize tại handler trước response — service trả raw payload đầy đủ; handler quyết định cái gì expose ra client. Tách trách nhiệm rõ ràng: service không biết security policy, handler không biết business logic.
Pattern này áp cho mọi endpoint write-side Shop API tương lai: B71 Stripe payment create_payment_intent, B116 register user, B117 admin update_product_price, B135 admin restock_inventory.
Tổng Kết
- Migration 10
idempotency_keys: DB-backed cache 7 column + 2 index (KHÔNG Redis ở B66 — preview G19 production scale). - Idempotency-Key middleware Stripe-style: hash SHA256 body + cache 24h + verify same request body (Stripe rule).
- Cache scope: per-user + per-endpoint (key conflict cross-user OK vì query với
WHERE key=$1 AND user_id=$2). - Apply middleware selective: chỉ
POST /orders+ tương laiPOST /payments(KHÔNG global) — Stripe pattern. CreateOrderDto3 field: items (length 1-50), payment_method (B43 internally tagged enum), note? (Option default).with_retry(f, 3)retrySerializationFailuretransient SQLSTATE 40001/40P01 (B55 continued); permanent error fail-fast.build_order_responsehelper: fetch items JOIN products + fetch payment + sanitize PII trước khi expose response.- PII sanitize lock: mask
account_number+phonelast 4 digit (***5678); giữ nguyênpayment_intent_idStripe vì là ID public. - Service layer pattern lock: handler thin (30-50 dòng/endpoint), service fat (transaction + business logic), DTO transform tại handler boundary.
- 5 verify test: success + replay same key + mismatch body (422) + insufficient stock (422) + verify stock decrement.
- Cache 2xx + 4xx, KHÔNG 5xx — server error có thể transient, retry an toàn re-execute logic.
- Apply method POST + PUT, GET/DELETE/HEAD/OPTIONS skip vì idempotent HTTP semantic native.
- File path lock: NEW
crates/shop-api/src/middleware/idempotency.rs+ NEWcrates/shop-common/src/dto/order.rs+ extendcrates/shop-api/src/routes/orders.rs+ migration20260615190000_create_idempotency_keys.sql; workspace dep mớisha2 = "0.10".
Bài Tập Củng Cố
Tự trả lời, đáp án ở cuối:
- Idempotency-Key cache 24h — tại sao chọn 24h thay 1h hoặc 7 ngày? Trade-off storage vs UX cụ thể là gì?
- SHA256
request_hashverify body — pattern Stripe — kịch bản nào sẽ fail nếu KHÔNG check hash? Cho ví dụ cụ thể với Shop API. with_retry(f, 3)choSerializationFailure+ backoff exponential 10/20/40ms — tại sao max 3 attempts thay 5 hoặc 10? Tính toán worst-case latency.- PII sanitize
account_number → ***1234— tại sao masked thay omit entirely (xóa field)? Use case audit trail + debug cụ thể. - Service layer thin handler vs fat service — code review reject scenario nào? Cho ví dụ vi phạm với Shop API.
Đáp án
- TTL 24h vs 1h vs 7 ngày + trade-off storage UX: Tại sao 24h: Stripe standard 24h dựa trên 3 observation thực tế. (a) Mobile background retry: app mobile mở rồi đặt sang background, OS suspend network 10-30 phút sau, khi user mở lại app phải retry — 1h không đủ vì user có thể quên app cả tối. (b) Mạng kém + người dùng F5: mạng 3G/4G ở vùng quê Việt Nam request 1 lần fail rồi retry sau 2-3 giờ là bình thường; 24h đủ tolerance. (c) Server deploy rolling: deploy version mới mất 15-30 phút, request giữa lúc deploy có thể fail + client retry sau deploy xong → key vẫn còn hợp lệ. Trade-off vs 1h: 1h tiết kiệm storage (~24× ít row) nhưng UX kém — user retry sau 2h thấy đơn trùng (double-charge thực tế xảy ra) → ticket support, tổn thất uy tín nặng hơn cost storage. Trade-off vs 7 ngày: 7 ngày bảo vệ thừa — user retry sau 1 tuần là edge case <0.01%, không xứng đáng 7× storage cost; mặt khác key sống quá lâu tăng risk replay attack (attacker thu thập key cũ + body cũ rồi resend gây confusion). Lock Shop API: 24h vĩnh viễn; tương lai G19 production khi traffic cao có thể giảm xuống 12h theo Stripe optimization (Stripe Atlas blog 2024 confirm 95% retry xảy ra trong 6h đầu). Generalize: TTL idempotency cache tỉ lệ thuận với "retry window" thực tế của client phổ biến trong domain — e-commerce mobile-heavy 24h, b2b SaaS 7 ngày (script ETL run hàng tuần), payment processor 7 ngày (reconciliation kéo dài).
- SHA256 request_hash verify + scenario fail nếu KHÔNG check: Mục đích check hash: phòng client gửi cùng
Idempotency-Keynhưng body khác nhau — server tự bảo vệ semantic "1 key = 1 logical operation". Scenario 1 — client bug nhầm key: user nhấp "Đặt hàng" lần 1 với cart {iPhone, MacBook}, app gen UUID key A, gửi thành công. User quay lại sửa cart bỏ MacBook chỉ giữ iPhone, nhấp "Đặt hàng" lần 2 — bug client KHÔNG gen UUID mới mà tái dùng key A. KHÔNG check hash: server trả response cũ (đơn cũ với cart {iPhone, MacBook}); user thấy "OK đặt thành công" nhưng thực tế đơn không khớp cart mới → ticket "tôi sửa cart sao đơn vẫn cũ?". Có check hash: body hash khác → 422 IDEMPOTENCY_KEY_MISMATCH → client UI hiển thị "vui lòng thử lại" + tự gen key mới retry → đơn mới đúng cart. Scenario 2 — replay attack: attacker thu thập key A từ logging chia sẻ + gửi POST với key A nhưng body khác (cart đắt hơn 100 lần). KHÔNG check hash: server có thể trả response thành công cũ (gây confusion) hoặc skip check stock (race tùy implementation). Có check hash: reject ngay 422, request thực tế không reach service layer. Scenario 3 — proxy retry pollution: AWS API Gateway retry với same key nhưng body bị corrupt (rare nhưng có). Hash verify phát hiện ngay → reject thay vì execute logic với body sai. Lock Shop API: SHA256 hash MANDATORY cho mọi idempotent endpoint; SHA256 không phải MD5 vì MD5 đã có collision known từ 2004, SHA256 đủ secure cho 2026+ (chống collision birthday attack 2^128 attempts). Hash hex formatformat!("{:x}", hasher.finalize())64 ký tự dễ store + grep log. Generalize: mọi pattern caching response theo key client-controlled phải hash request payload — Stripe, Square, PayPal, Twilio đều áp pattern. - Max 3 attempts + worst-case latency: Tại sao 3 attempts:
SerializationFailureSQLSTATE 40001/40P01 là transient — Postgres MVCC phát hiện concurrent transaction conflict (2 transaction đồng thời UPDATE cùng row, transaction sau abort phía Postgres để giữ ACID Serializable). Sau abort, retry transaction thường thành công ngay attempt thứ 2 (~80% case), attempt thứ 3 cover thêm 15% (~95% total), attempt thứ 4-5 chỉ cover thêm 4-5% nhưng latency tăng quá nhiều — diminishing return. Worst-case latency tính toán: attempt 1 fail → sleep 10ms → attempt 2 fail → sleep 20ms → attempt 3 fail → return Err. Tổng sleep = 10 + 20 = 30ms. Mỗi attempt có cost transaction ~10-30ms (FOR UPDATE row lock + 5 step + commit). Worst-case 3 × 30ms + 30ms sleep = 120ms (chấp nhận được, user thấy delay nhỏ). Tại sao KHÔNG 5 attempts: 5 attempts → worst-case 5 × 30ms + (10+20+40+80) = 150ms transaction + 150ms sleep = 300ms. UX bắt đầu thấy "chậm" (Google research bounce 32% khi > 3s, threshold "fast" 300ms). Hơn nữa nếu 3 attempts cùng fail có nghĩa hot row contention nặng — retry thêm chỉ tốn cycle DB, không giải quyết root cause. Tại sao KHÔNG 1 attempt: 1 attempt = không retry, client phải tự retry → tăng round-trip mạng (50-200ms RTT) > backoff in-process 10ms; UX kém hơn server-side retry. Backoff exponential 10/20/40ms: cho phép concurrent transaction conflict tự nhiên giải tỏa — wave 1 fail collide, sleep tạm 10ms, wave 2 thử lại với offset thời gian khác → collision probability giảm exponentially. Pattern industry chuẩn AWS SDK (exponential backoff + jitter G19 sẽ thêm jitter ±20% chống thundering herd). Lock Shop API: max 3 attempts vĩnh viễn cho mọi service Shop API tương lai (B71 Stripe webhook, B98 admin bulk, B135 inventory restock). Generalize: số attempt retry transient = giao điểm "marginal success rate" vs "marginal latency" — thường 2-5 attempts là sweet spot; > 5 chỉ có sense cho async background job không user-facing. - Mask vs omit entirely + use case audit trail: Tại sao mask thay omit: omit (xóa field hoàn toàn) phá vỡ wire-format contract — frontend/mobile expect field
account_numbertồn tại để render UI "Tài khoản kết thúc ****1234"; field biến mất → UI null check + fallback rỗng, dev confusion. Mask giữ field + giữ shape JSON, chỉ thay value bằng pattern an toàn. Use case 1 — audit trail compliance: PCI-DSS yêu cầu log "tài khoản nào liên quan giao dịch X" nhưng KHÔNG được log full account number; mask***1234đủ để admin so sánh "đúng là tài khoản của tôi" qua last 4 digit (chuẩn ngân hàng Việt Nam + Vietcombank/Techcombank app hiển thị y hệt). Use case 2 — debug refund/dispute: customer gọi support "đơn 123 chưa hoàn tiền", admin check log thấyaccount_number: "***5678"+ customer xác nhận "đúng số tài khoản của tôi kết thúc 5678" → verify identity nhanh KHÔNG cần customer đọc full account ra phone (security risk eavesdrop). Use case 3 — fraud detection ML: model phát hiện gian lận dùng pattern last 4 digit làm feature ("cùng last 4 digit của 5 thẻ khác nhau trong 1 giờ → suspicious") — omit field hoàn toàn mất tính năng này. Phone mask: tương tự,+84912345678 → ***5678đủ user nhận diện ("đúng đầu số 567 của tôi") + admin distinguish 2 customer khác phone. Tại sao giữ payment_intent_id Stripe: ID Stripe (pi_3ABC123def456) là public identifier hiển thị ngay trên Stripe Dashboard, dev cần debug refund/dispute phải copy ID này → search Stripe UI; mask sẽ phá workflow. ID Stripe KHÔNG chứa PII trực tiếp (chỉ là pointer tới Stripe object, cần Stripe API key mới truy cập detail) → giữ raw an toàn. Lock Shop API: mask pattern***<last 4>MANDATORY cho mọi PII numeric field response (account_number, phone, credit_card_last4 đã masked tại source); raw cho ID public (Stripe payment_intent_id, order_id, request_id UUID). Generalize: rule of thumb — field client cần render UI thì giữ + mask, field debug/audit thì giữ raw, field secret (password_hash, api_key) thì omit entirely. - Service layer thin handler + fat service — scenario reject code review: Vi phạm 1 — Business logic trong handler: dev mới viết handler
create_ordercó body 200 dòng, làm cả: validate stock thủ công (SELECT stock + check >= quantity), insert order, insert items, decrement stock, build response — tất cả trong handler trực tiếp gọisqlx. Code review reject vì: (a) Test khó — phải mock cả HTTP layer + DB layer cùng lúc thay vì test service layer thuần với pool thật; (b) Tái sử dụng kém — B98 admin bulk order import không thể reuse, phải copy-paste 200 dòng; (c) Race condition không có pessimistic lock → InsufficientStock không trigger đúng concurrent. Fix: tách logic vàoshop_db::orders::create_order_atomic(B54), handler chỉ gọi + map error. Vi phạm 2 — DTO leak xuống service: handler truyền nguyênCreateOrderDtoobject xuống service:create_order_atomic(&pool, dto). Code review reject vì: (a) Service depend HTTP wire format — đổi DTO field thêm/bớt phải đổi service ngay cả khi business logic không đổi; (b)shop-workerG21 background job tạo order quaJobOrderCreatestruct riêng — phải convert sang DTO chỉ để call service, vô lý; (c) Validate ở 2 nơi (validator crate + service tự check lần 2). Fix: service nhận type primitive hoặc struct riêng (CreateOrderItem), handler convert DTO → service input. Vi phạm 3 — Sanitize PII trong service: service trảOrderRowvớipayment_payloadđã masked sẵn. Code review reject vì: (a) Service không biết security policy — nếu admin API muốn xem full account_number để debug fraud thì sao? (b) Single source-of-truth raw payload nằm ở DB, ai cần truy cập sanitized thì làm tại boundary tương ứng. Fix: service trả raw; handler quyết định mask dựa trên role/permission (B112 sau khi có RBAC sẽ thêm logicif !current_user.is_admin() { sanitize() }). Vi phạm 4 — HTTP concept trong service: service trả(StatusCode, Json)tuple hoặc implIntoResponse. Code review reject vì: service phải HTTP-agnostic — cùng logic phải dùng được cho gRPC G29, CLI G29, background worker G21. Fix: service trảResult<OrderRow, OrderError>; handler mapOrderError → AppError → HTTP Response(B55 + B66 pattern). Lock Shop API: 4 vi phạm trên đều là red-flag code review reject; pattern handler thin (30-50 dòng) + service fat + DTO boundary + PII sanitize tại handler vĩnh viễn. Generalize: clean architecture nguyên tắc — domain (service) không depend transport (HTTP); transport biết về domain nhưng domain KHÔNG biết về transport.
Bài Tiếp Theo
Bài 67: GET /orders List + Filter — implement GET /api/v1/orders với filter status/user_id/date range, pagination cursor-based, populate items + payment cho mỗi order qua JOIN, áp pattern N+1 avoidance B64 continued.
