Mục lục
- Mục Tiêu Bài Học
- Migration: Thêm deleted_at + Trigger updated_at
- Audit Log Table — Track Mọi Mutation
- ETag Header Implementation
- Refactor delete_product → Soft Delete
- Restore Endpoint POST /products/{slug}/restore
- Filter ?include_deleted=true Cho Admin View
- Service Layer Wrap — Audit Log Pattern
- Verify End-To-End
- Tổng Kết
- Bài Tập Củng Cố
- Bài Tiếp Theo
Mục Tiêu Bài Học
Sau bài học, bạn sẽ:
- Implement ETag header cho
GET /api/v1/products/{slug}+If-None-Matchtrả 304 Not Modified. - Tạo migration thêm cột
deleted_at TIMESTAMPTZcho soft delete + partial index + trigger updated_at. - Refactor
delete_producttừ hard delete → soft delete (UPDATE deleted_at). - Implement
POST /products/{slug}/restoreendpoint admin un-delete. - Tạo trigger Postgres
update_updated_at_columnauto-refresh + bảngaudit_logs7 column. - Thêm filter
?include_deleted=truecho admin view, default exclude với user. - Verify pattern reuse cho orders/users/cart trong các bài Group 7 tiếp theo.
Migration: Thêm deleted_at + Trigger updated_at
Tạo migration thứ 6 trong workspace Shop API (5 migration đã có sau Group 6: create_products, add_products_tags, create_orders, create_payments, add_products_metadata):
sqlx migrate add --source crates/shop-db/migrations add_products_soft_delete
# Creating crates/shop-db/migrations/20260615160000_add_products_soft_delete.sql
Mở file SQL vừa tạo và viết schema change:
-- File: crates/shop-db/migrations/20260615160000_add_products_soft_delete.sql
-- Soft delete column
ALTER TABLE products
ADD COLUMN deleted_at TIMESTAMPTZ;
-- Partial index cho query active products
CREATE INDEX products_active_idx ON products(deleted_at)
WHERE deleted_at IS NULL;
-- Trigger function auto-update updated_at
CREATE OR REPLACE FUNCTION update_updated_at_column()
RETURNS TRIGGER AS $$
BEGIN
NEW.updated_at = NOW();
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
-- Trigger bind products
CREATE TRIGGER products_updated_at
BEFORE UPDATE ON products
FOR EACH ROW
EXECUTE FUNCTION update_updated_at_column();
Apply migration:
sqlx migrate run --source crates/shop-db/migrations
# Applied 20260615160000/migrate add_products_soft_delete
3 quyết định lock vĩnh viễn từ migration này:
deleted_at TIMESTAMPTZ NULLABLE—NULL= row active,NOT NULL= soft deleted (timestamp lúc xoá). Pattern lock cho mọi entity Shop API cần soft delete (products, orders, users, categories, reviews). Cart KHÔNG cần soft delete (dữ liệu ephemeral, xoá hẳn khi user clear).- Partial index
WHERE deleted_at IS NULL— chỉ index row active. Đa số query là "list active products" → partial index giảm 30-50% size so với full index, tăng tốc scan vì B-tree nhỏ hơn. Postgres tự dùng partial index khi WHERE clause match điều kiện. - Trigger
update_updated_at_column— auto refreshupdated_atmỗiUPDATEtại DB level. KHÔNG setupdated_at = NOW()thủ công trong Rust handler — dev quên làupdated_atstale, ETag sai, cache nhầm. Trigger guarantee consistency cross mọi mutation (handler HTTP, admin SQL trực tiếp, worker job).
Hàm update_updated_at_column() reuse cho mọi bảng cần auto-update updated_at — viết 1 lần dùng N lần. Khi tạo bảng mới (orders, users, ...) chỉ cần CREATE TRIGGER <table>_updated_at BEFORE UPDATE ON <table> FOR EACH ROW EXECUTE FUNCTION update_updated_at_column(); trong migration tương ứng.
Audit Log Table — Track Mọi Mutation
Audit log là requirement bắt buộc cho mọi e-commerce production: ai đổi gì lúc nào, đặc biệt cho admin action sensitive. Tạo migration thứ 7:
sqlx migrate add --source crates/shop-db/migrations create_audit_log
# Creating crates/shop-db/migrations/20260615160100_create_audit_log.sql
Schema bảng audit_logs:
-- File: crates/shop-db/migrations/20260615160100_create_audit_log.sql
CREATE TABLE audit_logs (
id BIGSERIAL PRIMARY KEY,
table_name TEXT NOT NULL,
row_id BIGINT NOT NULL,
action TEXT NOT NULL
CHECK (action IN ('insert', 'update', 'delete', 'restore')),
actor_user_id BIGINT, -- NULL cho system action
changes JSONB, -- old vs new value diff
request_id TEXT, -- correlation HTTP request (B39)
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX audit_logs_table_row_idx
ON audit_logs(table_name, row_id, created_at DESC);
CREATE INDEX audit_logs_actor_idx
ON audit_logs(actor_user_id, created_at DESC);
CREATE INDEX audit_logs_request_idx
ON audit_logs(request_id);
Giải nghĩa 7 column:
table_name + row_id— loose reference đến row được audit. KHÔNG dùng foreign key cứng để tránh coupling 1 bảng audit với nhiều entity khác nhau (products, orders, users, categories), và cho phép giữ audit log khi row gốc bị hard-delete sau N năm.action— 4 enum valueinsert/update/delete/restoremap đúng vào CRUD operation Shop API.actor_user_id—NULLcho system action (cron job, webhook), cóuser_idcho user action (admin update product, customer cancel order).changes JSONB— diff old vs new value dạng JSON object. JSONB binary format cho phép querychanges -> 'stock'để filter audit theo field.request_id— correlation với HTTP request từ middleware B39. Khi có incident, trace audit log từ một request cụ thể qua field này.created_at— timestamp ghi log, defaultNOW().
3 index phục vụ 3 query pattern phổ biến: (a) audit per resource "lịch sử thay đổi của product X" qua (table_name, row_id, created_at DESC); (b) audit per actor "admin Y đã làm gì hôm qua" qua (actor_user_id, created_at DESC); (c) trace per request "request UUID Z gây ra những thay đổi gì" qua request_id.
Lock decision Shop API: audit log ghi qua service layer Rust chứ KHÔNG qua trigger Postgres. Lý do: service layer có sẵn request_id từ middleware Extension, có sẵn actor_user_id từ auth context, control transaction boundary chính xác. Trigger Postgres không biết context HTTP request, phải truyền qua session variable phức tạp — lợi không bù bất tiện.
Audit log áp dụng cho products, orders, users, payments — các entity có business value cao. Cart KHÔNG audit (ephemeral data, user thường xuyên thêm/xoá item, audit log sẽ phình vô ích).
ETag Header Implementation
Tạo file helper riêng cho ETag logic:
// File: crates/shop-api/src/etag.rs
use axum::http::{header, HeaderMap};
use chrono::{DateTime, Utc};
/// Build weak ETag từ updated_at timestamp.
/// Format theo RFC 9110 section 8.8.3: W/"<identifier>"
pub fn weak_etag(updated_at: DateTime<Utc>) -> String {
format!(r#"W/"{}""#, updated_at.timestamp())
}
/// Check header If-None-Match match với ETag hiện tại.
pub fn matches_etag(headers: &HeaderMap, current: &str) -> bool {
headers
.get(header::IF_NONE_MATCH)
.and_then(|v| v.to_str().ok())
.map(|v| v.trim() == current)
.unwrap_or(false)
}
Wire module trong crates/shop-api/src/lib.rs (hoặc main.rs nếu file gốc Shop API):
// File: crates/shop-api/src/lib.rs (extend)
pub mod etag;
Refactor handler get_product trong crates/shop-api/src/routes/products.rs để integrate ETag:
// File: crates/shop-api/src/routes/products.rs (refactor get_product)
use axum::extract::State;
use axum::http::{header, HeaderMap, StatusCode};
use axum::response::{IntoResponse, Response};
use axum::Json;
use crate::etag::{matches_etag, weak_etag};
use crate::extractors::AppPath;
use crate::state::AppState;
use shop_common::dto::ProductResponseDto;
use shop_common::error::AppError;
use shop_db::products as db;
pub async fn get_product(
State(state): State<AppState>,
AppPath(slug): AppPath<String>,
headers: HeaderMap,
) -> Result<Response, AppError> {
// Load row, mặc định exclude soft deleted (include_deleted = false)
let row = db::find_by_slug(&state.db, &slug, false)
.await?
.ok_or_else(|| AppError::NotFound(
format!("product '{}' not found", slug)
))?;
let etag = weak_etag(row.updated_at);
// Check If-None-Match → trả 304 nếu match
if matches_etag(&headers, &etag) {
return Ok(StatusCode::NOT_MODIFIED.into_response());
}
// Build response với ETag header
let dto = ProductResponseDto::from(row);
let mut response = Json(dto).into_response();
response.headers_mut().insert(
header::ETAG,
etag.parse().unwrap(),
);
Ok(response)
}
Verify flow end-to-end:
# Request 1 — fetch lần đầu, lấy ETag
curl -i http://localhost:3000/api/v1/products/iphone-15
# < HTTP/1.1 200 OK
# < ETag: W/"1718438400"
# < Content-Type: application/json
# < {"id": 1, "name": "iPhone 15", "slug": "iphone-15", ...}
# Request 2 — kèm If-None-Match → 304 Not Modified
curl -i http://localhost:3000/api/v1/products/iphone-15 \
-H 'If-None-Match: W/"1718438400"'
# < HTTP/1.1 304 Not Modified
# < (no body)
# Update product → updated_at thay đổi
curl -X PATCH http://localhost:3000/api/v1/products/iphone-15 \
-H 'Content-Type: application/json' \
-d '{"stock": 5}'
# Request 3 — kèm ETag cũ → server thấy updated_at mới, trả 200 với ETag mới
curl -i http://localhost:3000/api/v1/products/iphone-15 \
-H 'If-None-Match: W/"1718438400"'
# < HTTP/1.1 200 OK
# < ETag: W/"1718438500"
# < {"id": 1, ..., "stock": 5}
Trigger Postgres products_updated_at đảm bảo updated_at luôn refresh khi UPDATE chạy — ETag tự động chính xác mà không cần handler set thủ công. Pattern lock cho mọi GET single resource endpoint Shop API: extract updated_at → build weak ETag → check If-None-Match → 304 hoặc 200 + ETag header.
Refactor delete_product → Soft Delete
Cập nhật ProductRow trong crates/shop-db/src/products.rs thêm field deleted_at:
// File: crates/shop-db/src/products.rs (extend ProductRow)
use chrono::{DateTime, Utc};
use rust_decimal::Decimal;
use serde_json::Value;
pub struct ProductRow {
pub id: i64,
pub name: String,
pub slug: String,
pub price: Decimal,
pub stock: i32,
pub description: Option<String>,
pub tags: Vec<String>,
pub metadata: Value,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
pub deleted_at: Option<DateTime<Utc>>, // ← NEW B62
}
Refactor find_by_slug nhận thêm tham số include_deleted:
// File: crates/shop-db/src/products.rs (refactor find_by_slug)
use sqlx::PgPool;
pub async fn find_by_slug(
pool: &PgPool,
slug: &str,
include_deleted: bool,
) -> Result<Option<ProductRow>, sqlx::Error> {
if include_deleted {
sqlx::query_as!(
ProductRow,
r#"SELECT id, name, slug, price, stock, description, tags, metadata,
created_at, updated_at, deleted_at
FROM products WHERE slug = $1"#,
slug
)
.fetch_optional(pool)
.await
} else {
sqlx::query_as!(
ProductRow,
r#"SELECT id, name, slug, price, stock, description, tags, metadata,
created_at, updated_at, deleted_at
FROM products
WHERE slug = $1 AND deleted_at IS NULL"#,
slug
)
.fetch_optional(pool)
.await
}
}
Thêm 2 function mới: soft_delete_product và restore_product:
// File: crates/shop-db/src/products.rs (NEW functions)
/// Soft delete: set deleted_at = NOW().
/// Trả về số row affected — 0 nghĩa là không tìm thấy
/// hoặc đã soft deleted rồi.
pub async fn soft_delete_product(
pool: &PgPool,
slug: &str,
) -> Result<u64, sqlx::Error> {
let result = sqlx::query!(
r#"UPDATE products SET deleted_at = NOW()
WHERE slug = $1 AND deleted_at IS NULL"#,
slug
)
.execute(pool)
.await?;
Ok(result.rows_affected())
}
/// Restore: set deleted_at = NULL.
/// Trả về số row affected — 0 nghĩa là không tìm thấy
/// hoặc row chưa bao giờ soft deleted.
pub async fn restore_product(
pool: &PgPool,
slug: &str,
) -> Result<u64, sqlx::Error> {
let result = sqlx::query!(
r#"UPDATE products SET deleted_at = NULL
WHERE slug = $1 AND deleted_at IS NOT NULL"#,
slug
)
.execute(pool)
.await?;
Ok(result.rows_affected())
}
Update list_products và search_products filter deleted_at IS NULL mặc định (default exclude soft deleted) — admin endpoint sẽ override flag include_deleted=true ở Bước 7.
Refactor handler delete_product trong crates/shop-api/src/routes/products.rs:
// File: crates/shop-api/src/routes/products.rs (refactor delete_product)
use crate::responses::NoContent;
pub async fn delete_product(
State(state): State<AppState>,
AppPath(slug): AppPath<String>,
) -> Result<NoContent, AppError> {
let affected = db::soft_delete_product(&state.db, &slug).await?;
if affected == 0 {
return Err(AppError::NotFound(
format!("product '{}' not found or already deleted", slug)
));
}
Ok(NoContent)
}
Behavior từ client perspective KHÔNG đổi: DELETE /api/v1/products/{slug} vẫn trả 204 No Content khi thành công, vẫn 404 NotFound khi slug không tồn tại. Khác biệt nằm ở DB: row vẫn còn, chỉ bị flag deleted_at set. Audit log giữ nguyên trace, future analytics query lịch sử order kèm tên product đã "xoá" vẫn hoạt động qua join.
Restore Endpoint POST /products/{slug}/restore
Tạo handler restore — pattern action verb POST đã lock B61:
// File: crates/shop-api/src/routes/products.rs (NEW handler)
pub async fn restore_product(
State(state): State<AppState>,
AppPath(slug): AppPath<String>,
) -> Result<Json<ProductResponseDto>, AppError> {
let affected = db::restore_product(&state.db, &slug).await?;
if affected == 0 {
return Err(AppError::NotFound(format!(
"product '{}' not found or not soft-deleted",
slug
)));
}
// Reload row đã restore để trả về cho client
let row = db::find_by_slug(&state.db, &slug, false)
.await?
.ok_or_else(|| AppError::Internal(
"product restored but not findable".into()
))?;
Ok(Json(ProductResponseDto::from(row)))
}
Wire route mới trong routes() function (B61 lock action verb pattern):
// File: crates/shop-api/src/routes/products.rs (extend routes)
use axum::routing::{get, post};
use axum::Router;
use axum::extract::DefaultBodyLimit;
pub fn routes() -> Router<AppState> {
Router::new()
.route(
"/products",
get(list_products).post(create_product),
)
.route(
"/products/{slug}",
get(get_product).patch(update_product).delete(delete_product),
)
// NEW B62 — action verb pattern lock B61
.route(
"/products/{slug}/restore",
post(restore_product),
)
.route(
"/products/export.ndjson",
get(export_products_ndjson),
)
.route(
"/products/import.ndjson",
post(import_products_ndjson)
.layer(DefaultBodyLimit::max(10 * 1024 * 1024)),
)
}
Verify flow soft delete + restore:
# Soft delete
curl -X DELETE http://localhost:3000/api/v1/products/iphone-15
# < HTTP/1.1 204 No Content
# GET ngay sau → 404 (default exclude soft deleted)
curl http://localhost:3000/api/v1/products/iphone-15
# < HTTP/1.1 404 Not Found
# < {"error":"not found: product 'iphone-15' not found","code":"NOT_FOUND",...}
# Restore qua action verb POST
curl -X POST http://localhost:3000/api/v1/products/iphone-15/restore
# < HTTP/1.1 200 OK
# < {"id":1,"name":"iPhone 15","slug":"iphone-15",...}
# GET lại → 200 OK, row đã active trở lại
curl http://localhost:3000/api/v1/products/iphone-15
# < HTTP/1.1 200 OK
# < ETag: W/"<new_unix>"
# < {"id":1,...}
# Restore khi không phải soft deleted → 404 (defensive check)
curl -X POST http://localhost:3000/api/v1/products/iphone-15/restore
# < HTTP/1.1 404 Not Found
# < {"error":"not found: product 'iphone-15' not found or not soft-deleted",...}
Endpoint restore chỉ dành cho admin. Hiện tại chưa wire auth middleware (G11+G12 sẽ deep), nhưng route đã sẵn sàng cho admin gate ở B112 — chỉ cần thêm layer require_role("admin") vào sub-router.
Filter ?include_deleted=true Cho Admin View
Extend ProductSearchQuery trong crates/shop-common/src/dto/product.rs thêm field admin:
// File: crates/shop-common/src/dto/product.rs (extend struct)
pub struct ProductSearchQuery {
// ... 9 field existing từ B59 + B60
pub name: Option<String>,
pub tag: Option<String>,
pub min_price: Option<Decimal>,
pub max_price: Option<Decimal>,
pub sort: String,
pub page: u32,
pub per_page: u32,
pub has_metadata_key: Option<String>,
pub metadata_contains: Option<Value>,
// NEW B62 — admin only, default false
#[serde(default)]
pub include_deleted: bool,
}
Sync field include_deleted sang ProductFilter tại DB layer và refactor apply_filter nhận thêm tham số:
// File: crates/shop-db/src/products.rs (refactor apply_filter)
fn apply_filter(
qb: &mut QueryBuilder<'_, Postgres>,
filter: &ProductFilter,
) {
// NEW B62 — default exclude soft deleted
if !filter.include_deleted {
qb.push(" AND deleted_at IS NULL");
}
// ... 6 conditional filter existing (name, min_price, max_price,
// tag, has_metadata_key, metadata_contains)
if let Some(name) = &filter.name {
qb.push(" AND name ILIKE ").push_bind(format!("%{}%", name));
}
// ... (giữ nguyên các filter khác)
}
Pattern enforcement — user-facing endpoint luôn force include_deleted = false bất kể client gửi gì, admin endpoint mới tôn trọng query parameter:
// File: crates/shop-api/src/routes/products.rs (handler list_products user-facing)
pub async fn list_products(
State(state): State<AppState>,
AppQuery(mut query): AppQuery<ProductSearchQuery>,
) -> Result<Json<ProductListResponse>, AppError> {
// User endpoint LUÔN exclude soft deleted, bỏ qua gì client gửi
query.include_deleted = false;
let filter = ProductFilter::from(query);
let (rows, total) = db::search_products(&state.db, filter).await?;
// ... build response
}
Admin endpoint (preview, sẽ implement đầy đủ B112 sau khi auth):
// File: crates/shop-api/src/routes/admin/products.rs (preview B112)
pub async fn admin_list_products(
State(state): State<AppState>,
_admin: AdminUser, // B112 auth extractor
AppQuery(query): AppQuery<ProductSearchQuery>,
) -> Result<Json<ProductListResponse>, AppError> {
// Admin endpoint TÔN TRỌNG query.include_deleted client gửi
let filter = ProductFilter::from(query);
let (rows, total) = db::search_products(&state.db, filter).await?;
// ... build response
}
3 decision lock Shop API:
- User endpoint default exclude — bất kể client gửi
?include_deleted=true, handler force vềfalse. Bảo vệ semantic "user không thấy data đã xoá". - Admin endpoint respect flag — gate qua extractor
AdminUser(B112), client gửi?include_deleted=truemới được phép thấy soft deleted. - Restore endpoint admin only — wrap qua sub-router
/api/v1/admin/*trong B112, hiện tại public tạm thời để verify pattern.
Service Layer Wrap — Audit Log Pattern
Tạo helper service ghi audit log trong scope transaction:
// File: crates/shop-db/src/audit.rs (NEW)
use serde_json::Value;
use sqlx::{Postgres, Transaction};
/// Ghi 1 row audit log trong scope transaction.
/// Transaction-scoped để audit + mutation cùng commit hoặc rollback atomic.
pub async fn log_action(
tx: &mut Transaction<'_, Postgres>,
table_name: &str,
row_id: i64,
action: &str,
actor_user_id: Option<i64>,
changes: Option<Value>,
request_id: Option<&str>,
) -> Result<(), sqlx::Error> {
sqlx::query!(
r#"
INSERT INTO audit_logs
(table_name, row_id, action, actor_user_id, changes, request_id)
VALUES ($1, $2, $3, $4, $5, $6)
"#,
table_name,
row_id,
action,
actor_user_id,
changes,
request_id
)
.execute(&mut **tx)
.await?;
Ok(())
}
Wire module trong crates/shop-db/src/lib.rs:
// File: crates/shop-db/src/lib.rs (extend)
pub mod audit;
pub mod orders;
pub mod payments;
pub mod pool;
pub mod products;
Refactor soft_delete_product wrap transaction + audit:
// File: crates/shop-db/src/products.rs (NEW soft_delete_with_audit)
use crate::audit;
use sqlx::PgPool;
/// Soft delete trong transaction kèm audit log.
/// Pattern lock Shop API: get current → mutation → audit → commit.
pub async fn soft_delete_with_audit(
pool: &PgPool,
slug: &str,
actor_user_id: Option<i64>,
request_id: Option<&str>,
) -> Result<u64, sqlx::Error> {
let mut tx = pool.begin().await?;
// Step 1 — get current state để biết row_id phục vụ audit
let row = sqlx::query!(
"SELECT id FROM products WHERE slug = $1 AND deleted_at IS NULL",
slug
)
.fetch_optional(&mut *tx)
.await?;
let row = match row {
Some(r) => r,
None => {
tx.rollback().await?;
return Ok(0);
}
};
// Step 2 — mutation
let result = sqlx::query!(
"UPDATE products SET deleted_at = NOW() WHERE id = $1",
row.id
)
.execute(&mut *tx)
.await?;
// Step 3 — audit log cùng transaction
audit::log_action(
&mut tx,
"products",
row.id,
"delete",
actor_user_id,
Some(serde_json::json!({ "deleted_at": "now" })),
request_id,
)
.await?;
// Step 4 — commit atomic
tx.commit().await?;
Ok(result.rows_affected())
}
Handler lấy request_id qua Extension<RequestId> đã lock B39, lấy actor_user_id từ auth context (B112 sẽ deep). Hiện tại placeholder None cho cả 2 chấp nhận:
// File: crates/shop-api/src/routes/products.rs (refactor delete_product
// với audit)
use axum::Extension;
use crate::middleware::RequestId;
pub async fn delete_product(
State(state): State<AppState>,
AppPath(slug): AppPath<String>,
Extension(request_id): Extension<RequestId>,
) -> Result<NoContent, AppError> {
let affected = db::soft_delete_with_audit(
&state.db,
&slug,
None, // actor_user_id — B112 sẽ wire từ JWT claims
Some(request_id.as_str()),
)
.await?;
if affected == 0 {
return Err(AppError::NotFound(
format!("product '{}' not found or already deleted", slug)
));
}
Ok(NoContent)
}
Pattern lock vĩnh viễn Shop API cho mọi mutation (products/orders/users/payments): begin transaction → get current state → mutation → log_action → commit. Nếu bất cứ step nào fail, transaction rollback toàn bộ — audit log không bao giờ phản ánh state không đúng. Template soft_delete_with_audit reuse cho update_with_audit, restore_with_audit, create_with_audit với cùng skeleton 4 bước.
Verify End-To-End
Migration + start service:
sqlx migrate run --source crates/shop-db/migrations
# Applied 20260615160000/migrate add_products_soft_delete
# Applied 20260615160100/migrate create_audit_log
cargo sqlx prepare --workspace
# query data written to .sqlx in the current directory
AUTO_MIGRATE=true cargo run -p shop-api
# shop-api listening on 0.0.0.0:3000
Test full lifecycle product:
# Create
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 + Location: /api/v1/products/iphone-15
# GET với ETag
curl -i http://localhost:3000/api/v1/products/iphone-15
# ETag: W/"1718438400"
# Conditional GET → 304
curl -i http://localhost:3000/api/v1/products/iphone-15 \
-H 'If-None-Match: W/"1718438400"'
# 304 Not Modified
# Soft delete
curl -X DELETE http://localhost:3000/api/v1/products/iphone-15
# 204 No Content
# Verify audit log
docker compose exec postgres psql -U shop -d shop_dev -c \
"SELECT id, table_name, row_id, action, request_id, created_at
FROM audit_logs ORDER BY created_at DESC LIMIT 5;"
# id | table_name | row_id | action | request_id | created_at
# ---+-----------+--------+--------+-------------------+---------------
# 1 | products | 1 | delete | 550e8400-e29b-... | 2026-06-15 ...
# Restore
curl -X POST http://localhost:3000/api/v1/products/iphone-15/restore
# 200 OK + body ProductResponseDto
# GET lại → 200 OK với ETag mới (trigger updated_at đã refresh)
curl -i http://localhost:3000/api/v1/products/iphone-15
# ETag: W/"1718438500" (khác giá trị cũ)
Suggested commit khi mọi bước verify pass: B62: ETag + soft delete + restore endpoint + audit log table.
Tổng Kết
- Migration 6 + 7:
add_products_soft_delete+create_audit_log. deleted_at TIMESTAMPTZ NULLABLEsoft delete pattern lock vĩnh viễn cho products/orders/users.- Partial index
WHERE deleted_at IS NULL— chỉ index active row, giảm size 30-50%. - Trigger Postgres
update_updated_at_columnauto-refreshupdated_atmỗi UPDATE. - ETag weak helper
W/"<unix_timestamp>"+matches_etagfunction trongcrates/shop-api/src/etag.rs. - GET single resource flow: load row → build ETag → check If-None-Match → 304 hoặc 200 + ETag header.
POST /products/{slug}/restoreendpoint action verb pattern (B61 lock continued).include_deletedflag chỉ admin endpoint respect; user endpoint default force false.audit_logstable 7 column (id + table_name + row_id + action + actor_user_id + changes JSONB + request_id + created_at) + 3 index.log_actionhelper transaction-scoped — wrap mọi mutation, audit + mutation atomic commit/rollback.- Service layer pattern lock: get current → mutation → audit → commit; template
soft_delete_with_auditreuse cho update/restore/create. - File path lock:
crates/shop-api/src/etag.rs,crates/shop-db/src/audit.rs, migration 6 + 7. - Foundation cho B63 (Categories Tree CRUD), B65 (orders soft delete), B112 (admin auth gate restore endpoint).
Bài Tập Củng Cố
Tự trả lời, đáp án ở cuối:
- Soft delete với
deleted_at TIMESTAMPTZ NULLABLE— partial indexWHERE deleted_at IS NULLgiải quyết vấn đề gì? So sánh với full index. - Trigger Postgres
update_updated_at_columnso với setupdated_attrong handler Rust — pros/cons mỗi cách? Shop API chọn cách nào và lý do. - ETag weak
W/"<unix>"vs strong"<sha256>"— Shop API chọn weak vì lý do gì? Trade-off false negative ra sao? POST /products/{slug}/restoreaction verb pattern — tại sao không dùngPATCH /products/{slug}với body{"deleted_at": null}?- Audit log nên ghi qua trigger Postgres hay service layer Rust? Shop API quyết định cái nào và lý do cụ thể.
Đáp án
- Partial index
WHERE deleted_at IS NULLvs full index: Full index trên cộtdeleted_atindex mọi row — cả active (deleted_at NULL) và soft deleted (deleted_at NOT NULL). Khi catalog có 1 triệu product với chỉ 10K bị soft deleted (1%), full index chứa cả 1 triệu entry — size lớn vô ích vì query phổ biến là "list active products" chỉ cần lookup vào 990K active row. Partial indexWHERE deleted_at IS NULLchỉ index 990K active row, bỏ qua 10K soft deleted — size nhỏ hơn ~1%, B-tree depth thấp hơn (tree càng thấp scan càng nhanh), RAM cache hit rate cao hơn (index nhỏ vừa RAM). Postgres planner tự nhận biết partial index khi WHERE clause của query match điều kiện partial:SELECT ... FROM products WHERE slug = $1 AND deleted_at IS NULLsẽ dùng partial indexproducts_active_idx+ indexproducts_slug_idxqua bitmap heap scan. Benchmark thực tế: catalog 10M product với 5% soft deleted, partial index size ~190MB vs full index ~200MB; query performance khác biệt 5-10% với workload high-throughput. Trade-off: partial index không phù hợp khi admin query soft deleted thường xuyên (admin dashboard "danh sách product đã xoá") — partial index không cover query đó, phải seq scan toàn bảng hoặc tạo thêm partial indexWHERE deleted_at IS NOT NULLriêng cho admin. Shop API chọn partial indexWHERE deleted_at IS NULLvì 95% query là user-facing read active product. Admin endpoint hiếm (1-2 lần/ngày) chấp nhận seq scan hoặc indexproducts_admin_deleted_idx ON products(deleted_at DESC) WHERE deleted_at IS NOT NULLriêng nếu cần. Pattern lock cho mọi entity Shop API tương lai (orders, users, categories) — partial index trên active rows là default. - Trigger Postgres vs set updated_at trong handler Rust: Set trong handler Rust — pros: code đọc rõ ý đồ, đặt
updated_at = NOW()bên cạnh field update khác, dev mới đọc 1 file biết flow đầy đủ; cons: dev phải nhớ set mỗi UPDATE, quên làupdated_atstale dẫn đến ETag sai + cache nhầm; mutation qua đường khác (admin SQL trực tiếp, worker job, manual data fix) sẽ KHÔNG refreshupdated_atvì bypass handler. Trigger Postgres — pros: guarantee consistency cross mọi mutation (handler HTTP, worker job, admin SQLUPDATE products SET stock = stock - 1 WHERE id = 5trực tiếp), KHÔNG phụ thuộc developer discipline, atomic với UPDATE trong cùng row-level operation; cons: invisible từ Rust code (dev đọc handler không thấyupdated_atở đâu, phải biết trigger tồn tại bên DB), 1 chút overhead BEFORE UPDATE call function plpgsql (microsecond, không đáng kể với workload bình thường). Shop API chọn trigger Postgres 3 lý do cụ thể: (a) Multi-source mutation — Shop API có nhiều path mutation: HTTP handler (B53), batch worker (G21 apalis job), admin CLI tool (G29shop-cli), SQL migration data fix manual; trigger là cách DUY NHẤT enforce consistency cross mọi path; (b) ETag dependupdated_atchính xác — ETag weak dùngupdated_at.timestamp(); nếuupdated_atstale, ETag sai → client cache nhầm content cũ, browser hiển thị stock 10 nhưng DB đã 5 → over-sell; (c) Audit log query — analytics query "row nào update gần đây" dựa vàoupdated_at, trigger guarantee timestamp đúng cho mọi UPDATE. Pitfall:UPDATE products SET name = 'X' WHERE name = 'X'(no-op UPDATE — value không đổi) — trigger BEFORE UPDATE vẫn fire, vẫn setupdated_at = NOW(). Behavior này acceptable cho Shop API (audit cũng log no-op UPDATE — useful "ai đã chạy UPDATE statement này"); workload có vấn đề mới optimize qua điều kiệnWHERE NEW.* IS DISTINCT FROM OLD.*. Pattern lock cho mọi bảng Shop API cóupdated_atcolumn. - ETag weak vs strong + Shop API chọn weak + trade-off false negative: Strong ETag format
"<hash>"không prefix — thường là SHA-256 truncate 16 bytes hoặc tương đương content hash. Đảm bảo: 2 response cùng strong ETag chắc chắn cùng content byte-by-byte (cả thứ tự key JSON, whitespace, encoding). Pros: chính xác tuyệt đối, không bao giờ false negative; client cache hit/miss luôn đúng. Cons: tốn CPU mỗi response — phải serialize JSON full → hash → ghép vào header; mỗi request thêm 100-500 microsecond overhead với product DTO 10 field. Weak ETag formatW/"<identifier>"với prefixW/theo RFC 9110 — identifier thường là timestamp Unix hoặc version counter. Đảm bảo yếu hơn: 2 response cùng weak ETag chỉ "có thể" cùng content — nếu update 2 lần trong cùng 1 giây trên cùng row,updated_at.timestamp()trả cùng giá trị Unix dù content đã đổi → ETag giống nhau dù content khác → false negative (client nghĩ cache vẫn valid trong khi server data đã đổi). Pros: rẻ CPU — chỉ cần đọc cộtupdated_atcó sẵn + convert timestamp; không serialize không hash; mỗi request thêm dưới 1 microsecond. Shop API chọn weak ETag 3 lý do: (a) Cộtupdated_atđã có sẵn ở mọi entity từ B51 schema lock — sử dụng tài nguyên có sẵn, không cần thêm logic compute; (b) Workload Shop API không có update tần suất cao — product update 1-5 lần/ngày (admin đổi giá, đổi stock), không phải update mỗi giây; trigger Postgres setupdated_at = NOW()với millisecond precision (TIMESTAMPTZchính xác microsecond Postgres native) nhưng ETag dùng.timestamp()rồng giây — false negative chỉ xảy ra khi 2 update cùng giây, gần như không gặp; (c) CPU rẻ cho catalog endpoint heavy traffic —GET /api/v1/products/{slug}là endpoint hot nhất (mỗi user xem product detail), strong ETag hash JSON full mỗi request tốn CPU không xứng cho gain marginal. Trade-off false negative cụ thể: nếu cần precision tuyệt đối (workload payment, audit log, banking) thì dùng strong ETag — tốn CPU nhưng correctness là tuyệt đối. Shop API workload catalog/order có thể chấp nhận false negative cực hiếm (chấp nhận edge case 2 update cùng giây dẫn đến stale cache 1 client). Mitigation cho workload chuyển đổi tương lai: nếu cần độ chính xác cao, switch sang weak ETag dạngW/"<updated_at_micro>"dùng microsecond timestamp (updated_at.timestamp_micros()) — collision rate giảm 1 triệu lần, vẫn rẻ CPU vì chỉ format integer. POST /products/{slug}/restorevsPATCH /products/{slug}body{"deleted_at": null}: PATCH approach dùng PATCH với body chứadeleted_at: nullnghe có vẻ REST puristic (PATCH là partial update, set field về null là partial). Vấn đề: (a) Expose internal columndeleted_atra wire API — client biết về implementation detail của Shop API, refactor schema (đổi tên column, đổi strategy soft delete) sẽ breaking change wire; (b) Permission boundary mờ — PATCH endpoint là user-facing (user update product họ own), restore là admin action; trộn 2 permission vào 1 endpoint phải check field-level "user thường không được setdeleted_at = null" — logic phức tạp, dễ sai; (c) Validation phức tạp — PATCH body có thể chứa nhiều field cùng lúc ({"deleted_at": null, "stock": 5, "name": "new"}); semantic không rõ — restore + update đồng thời? Order operation? Audit log phải log 2 action hay 1?; (d) Atomicity rủi ro — nếu restore thành công nhưng update field khác fail validation, rollback nào — toàn bộ hay chỉ phần fail?; (e) Naming wire format — client developer đọc body{"deleted_at": null}không hiểu intent "restore"; phải đọc docs để biết. Action verb POST approachPOST /products/{slug}/restoregiải quyết tất cả vấn đề trên: (a) Semantic rõ ràng từ URL — developer đọc URL biết "đây là restore action", không cần đọc docs; (b) Permission gate đơn giản — wrap endpoint trong sub-router/admin/*với middlewarerequire_role("admin"), KHÔNG check field-level; (c) Audit log clean — log actionrestorerõ ràng, không phải parse body PATCH detect "có phải restore không"; (d) KHÔNG expose internal column — refactor strategy soft delete tương lai (chuyển sangstatusenum thaydeleted_atcolumn) không breaking wire; (e) Body có thể empty hoặc chứa metadata restore — vd body{"reason": "admin manual restore"}log vào audit changes JSONB. Pattern lock Shop API B61 continued: action verb POST cho mọi non-CRUD operation thay đổi state —POST /orders/{id}/cancel,POST /payments/{id}/refund,POST /products/{slug}/restore,POST /carts/{id}/checkout. Pattern industry GitHub (POST /repos/{owner}/{repo}/pulls/{number}/merge) + Stripe (POST /v1/charges/{id}/capture) đều theo. Generalize: nếu một operation không fit CRUD đơn thuần (state machine, kích hoạt job, trigger workflow) → action verb POST endpoint riêng, KHÔNG ép vào PATCH.- Audit log ghi qua trigger Postgres vs service layer Rust + Shop API quyết định: Trigger Postgres approach — tạo trigger AFTER INSERT/UPDATE/DELETE trên bảng products + function plpgsql ghi vào
audit_logsquaINSERT INTO audit_logs (table_name, row_id, action, changes) VALUES ('products', NEW.id, TG_OP, row_to_json(NEW)). Pros: (a) Guarantee cross mọi mutation path — handler HTTP, worker job, admin SQL manual, migration data fix; trigger fire cho mọi UPDATE bất kể nguồn; (b) Atomic với mutation — audit + mutation trong cùng row-level operation, không thể bypass; (c) Schema enforcement — không thể tạo mutation không có audit; (d) Performance acceptable — 1 INSERT thêm vào audit_logs per mutation, overhead microsecond. Cons: (a) KHÔNG biết context HTTP — trigger không có truy cậprequest_id,actor_user_idtừ middleware/auth; phải truyền qua session variable Postgres (SET app.request_id = '...') phức tạp + dễ quên; (b) Logic phức tạp trong plpgsql — diff old/new value, format JSONB changes, conditional skip cho operation không cần audit — viết trong plpgsql language khó debug khó test; (c) Transaction scope khó control — trigger trong cùng transaction OK, nhưng async logic (gửi notification về Slack/email khi audit critical action) phải thông qua LISTEN/NOTIFY phức tạp; (d) Migration churn — mỗi lần đổi format audit phải migration sửa function plpgsql, deploy synchronize với code Rust. Service layer Rust approach — wrap mutation trong Rust function với 4 step: begin transaction → mutation → log_action → commit. Pros: (a) Context HTTP đầy đủ —request_idtừ Extension B39,actor_user_idtừ JWT claims B112,changesdiff dễ build trong Rust vớiserde_json::json!; (b) Logic Rust dễ test — unit test với mock pool, integration test với testcontainers; (c) Transaction control rõ — programmer thấy ngay khi nào commit/rollback; (d) Async friendly — sau commit, spawn task gửi notification, push event Redis, gọi webhook; (e) Refactor friendly — đổi format audit chỉ sửa file Rust, deploy 1 lần. Cons: (a) Developer discipline — dev phải nhớ wrap mọi mutation trong service layer, quên là mutation bypass audit; mitigation qua code review + lint rule + integration test verify audit log; (b) Bypass risk — admin chạyUPDATE products SET stock = 5 WHERE id = 1trực tiếp trong psql → KHÔNG có audit; mitigation qua Postgres role + policy ràng buộc admin role không được DML trực tiếp (G14). Shop API quyết định service layer Rust 3 lý do cụ thể: (a) Context HTTP là MANDATORY —request_idcorrelation với log + tracing (G19) là core requirement, trigger Postgres không có truy cập tự nhiên; (b) Logicchangesdiff phức tạp — diff old/new value, exclude field nhạy cảm (password_hash, payment_payload internal), format consistent với search/filter audit; viết Rust dễ test hơn plpgsql; (c) Async post-commit action — sau audit log critical action (admin delete order > $1000) cần gửi Slack alert, push Sentry, ghi metric Prometheus; pattern Rust spawn task sautx.commit().awaittự nhiên, plpgsql NOTIFY phức tạp. Mitigation bypass risk: G14 sẽ wire Postgres role policy ràng buộc admin role chỉ chạy qua app, không DML trực tiếp; production database read-write user làshop_appkhông phải admin. Pattern lock vĩnh viễn Shop API: audit log qua service layer Rust vớilog_actiontransaction-scoped helper, KHÔNG trigger Postgres.
Bài Tiếp Theo
Bài 63: Categories Tree CRUD — Adjacency List + Materialized Path — chi tiết tree structure cho categories: adjacency list (parent_id) vs materialized path (path TEXT), Postgres CTE recursive query, áp Shop API category tree endpoint GET /categories trả nested JSON.
