Mục lục
- Mục Tiêu Bài Học
- 3 Model Soft Delete: State-Based Vs Timestamp-Based
- Migration users Table — Soft Delete Pattern
- orders.status State Machine — Cancel Transition
- update_parent Categories — Cycle Detection Production
- Bulk Restore Endpoint
- GET /admin/audit-logs Filter Endpoint
- Storage Cost Audit Trail — Trade-Off Discussion
- 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ẽ:
- Áp dụng soft delete pattern cho 3 resource khác nhau: orders (cancel state), users (deactivate), categories (deleted_at).
- Phân biệt soft delete state-based (
orders.status) so với timestamp-based (deleted_at) và biết khi nào chọn cái nào. - Implement
update_parentcategories với cycle detection quaWITH RECURSIVE EXISTSsubquery. - Implement bulk restore endpoint
POST /admin/products/restore-bulkvới cap 100 slug/request. - Implement
GET /admin/audit-logsfilter endpoint vớiQueryBuilderdynamic SQL 4 filter. - Tạo migration
userstable với 4-state lifecycle (active/suspended/deactivated) +deleted_atcho GDPR (preparation cho Group 11 auth). - Hiểu trade-off audit trail vs storage cost và roadmap partition cho production.
3 Model Soft Delete: State-Based Vs Timestamp-Based
B62 đã lock timestamp-based soft delete cho products qua cột deleted_at TIMESTAMPTZ NULL + audit log table. B63 áp dụng cùng pattern cho categories. Khi tiến tới orders + users, semantic xóa khác nhau buộc phải chọn 1 trong 3 model:
- Timestamp-based (
deleted_at TIMESTAMPTZ) — đơn giản nhất, mỗi mutation chỉ cần setdeleted_at = NOW()thay DELETE. Use case: products, categories, users (B62 + B63 lock). Pros: pattern thống nhất mọi resource, restore dễ (set NULL), partial indexWHERE deleted_at IS NULLgiữ hot subset nhanh. Cons: mất semantic vì sao xóa — không biết user tự xóa hay admin ban, không biết refund hay duplicate. - State-based (
statusenum) — cột text với CHECK constraint giới hạn giá trị hợp lệ. Use case điển hình: orders với 5 trạng tháipending → paid → shipped → delivered+ transition phụcancelled. Pros: rich semantic phân biệt cancelled vs refunded vs returned (3 case khác nhau cho cùng "đã xóa khỏi flow active"), state machine cưỡng chế transition hợp lệ qua code. Cons: phức tạp hơn, mỗi state transition phải validate, query filter active phải combine theo state thay 1 cột NULL. - Combined (state + timestamp) — cả 2 cùng tồn tại trên 1 bảng. Use case: users với
status: active/suspended/deactivated+deleted_atriêng cho GDPR hard erase. Pros: multi-stage soft delete linh hoạt — user tự "deactivate" account vẫn giữ data 30 ngày restore, sau đó admin xóa PII quadeleted_attheo Article 17 GDPR. Cons: filter query phức tạp hơn — phải combine 2 điều kiện cho mọi list endpoint.
Lock decision Shop API qua bảng mapping:
Resource | Model | Cột tracking | Restore khả thi
products | Timestamp | deleted_at | SET NULL
categories | Timestamp | deleted_at | SET NULL
orders | State | status enum (cancelled) | KHÔNG (đơn đã hủy không revert)
users | Combined | status enum + deleted_at GDPR | SET status (deleted_at irreversible)
Điểm cốt lõi cần nhớ: orders KHÔNG có cột deleted_at — semantic "đơn hàng đã hủy" được biểu đạt qua status = 'cancelled'. Đây là exception duy nhất của soft delete lock B62 timestamp-based, đánh dấu vì orders mang giá trị tài chính — không bao giờ xóa khỏi DB (audit kế toán bắt buộc), chỉ chuyển sang state terminal.
Migration users Table — Soft Delete Pattern
Tạo migration 9 create_users. Bảng users hiện chưa có trong DB (B54 đã tạo orders.user_id BIGINT chưa kèm FK), nên migration này vừa tạo bảng mới vừa ALTER TABLE orders thêm constraint:
sqlx migrate add --source crates/shop-db/migrations create_users
-- File: crates/shop-db/migrations/20260615180000_create_users.sql
CREATE TABLE users (
id BIGSERIAL PRIMARY KEY,
email TEXT NOT NULL UNIQUE,
password_hash TEXT NOT NULL,
display_name TEXT NOT NULL,
phone TEXT,
avatar_url TEXT,
status TEXT NOT NULL DEFAULT 'active'
CHECK (status IN ('active', 'suspended', 'deactivated')),
deleted_at TIMESTAMPTZ, -- GDPR right-to-erasure hard delete
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
last_login_at TIMESTAMPTZ
);
-- Partial index — chỉ user active (hot path login)
CREATE INDEX users_email_active_idx
ON users(email) WHERE deleted_at IS NULL;
CREATE INDEX users_status_idx
ON users(status) WHERE deleted_at IS NULL;
CREATE INDEX users_active_idx
ON users(deleted_at) WHERE deleted_at IS NULL;
CREATE TRIGGER users_updated_at
BEFORE UPDATE ON users
FOR EACH ROW
EXECUTE FUNCTION update_updated_at_column();
-- Hoàn thành FK orders.user_id (B54 preview)
ALTER TABLE orders
ADD CONSTRAINT orders_user_fk
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE RESTRICT;
4-state lifecycle của users:
- active — user bình thường, login + checkout được, hiển thị mặc định trong mọi list admin.
- suspended — admin tạm khóa (vi phạm chính sách, đang debug fraud). User không login được nhưng data còn nguyên, restore qua admin UI.
- deactivated — user tự deactivate từ UI "Delete my account". Sau 30 ngày grace period nếu không activate lại, admin job set
deleted_at = NOW()GDPR hard erase. - deleted_at != NULL — GDPR Article 17 right-to-erasure đã thực thi, PII đã anonymize (email →
user-{id}@deleted.local, display_name → "Deleted User", phone NULL, avatar_url NULL). Row giữ lại để bảo toàn FKorders.user_idreference (đơn hàng cũ vẫn còn tồn tại theo luật kế toán Việt Nam giữ 10 năm).
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
Tạo skeleton crates/shop-db/src/users.rs cho B65 — chỉ định nghĩa UserRow struct + UserStatus enum, CRUD chi tiết để B71+ implement đầy đủ trong Group 11 Authentication:
// File: crates/shop-db/src/users.rs
use chrono::{DateTime, Utc};
#[derive(Debug, Clone, sqlx::FromRow)]
pub struct UserRow {
pub id: i64,
pub email: String,
pub password_hash: String,
pub display_name: String,
pub phone: Option<String>,
pub avatar_url: Option<String>,
pub status: String,
pub deleted_at: Option<DateTime<Utc>>,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
pub last_login_at: Option<DateTime<Utc>>,
}
// CRUD đầy đủ implement ở Group 11 Authentication (B101+)
// B65 chỉ cần struct + FK để extend orders.rs cancel_order audit actor
orders.status State Machine — Cancel Transition
B54 đã lock orders với cột status TEXT CHECK IN ('pending', 'paid', 'shipped', 'delivered', 'cancelled'). Bảng state transition hợp lệ:
From | To | Allowed | Lý do
pending | paid | YES | Customer thanh toán thành công
pending | cancelled | YES | Customer huỷ trước thanh toán
paid | shipped | YES | Warehouse xác nhận xuất kho
paid | cancelled | YES | Refund flow B71 (Stripe refund)
shipped | delivered | YES | Vận chuyển xác nhận giao
shipped | cancelled | NO | Đã giao vận chuyển — phải dùng return endpoint B68
delivered | cancelled | NO | Đã giao đến tay — phải dùng return endpoint B68
cancelled | bất kỳ | NO | Terminal state
Implement cancel_order trong crates/shop-db/src/orders.rs với 5 step kèm pessimistic row lock chống race condition giữa 2 admin cùng huỷ 1 đơn:
// File: crates/shop-db/src/orders.rs
use sqlx::PgPool;
use crate::audit;
pub async fn cancel_order(
pool: &PgPool,
order_id: i64,
actor_user_id: Option<i64>,
request_id: Option<&str>,
) -> Result<OrderRow, OrderError> {
let mut tx = pool.begin().await?;
// Step 1: Get current status với FOR UPDATE (row lock chống race)
let current = sqlx::query!(
"SELECT status FROM orders WHERE id = $1 FOR UPDATE",
order_id
)
.fetch_optional(&mut *tx)
.await?
.ok_or(OrderError::NotFound(order_id))?;
// Step 2: Validate state transition
match current.status.as_str() {
"pending" | "paid" => {} // OK to cancel
"shipped" | "delivered" | "cancelled" => {
tx.rollback().await?;
return Err(OrderError::InvalidTransition {
from: current.status,
to: "cancelled".into(),
});
}
_ => unreachable!("invalid status in DB — CHECK constraint vi phạm"),
}
// Step 3: Restore stock cho mọi item của order
sqlx::query!(
r#"
UPDATE products SET stock = stock + oi.quantity
FROM order_items oi
WHERE oi.order_id = $1 AND products.id = oi.product_id
"#,
order_id
)
.execute(&mut *tx)
.await?;
// Step 4: Update order status
let order = sqlx::query_as!(
OrderRow,
r#"
UPDATE orders SET status = 'cancelled'
WHERE id = $1
RETURNING id, user_id, total, status, created_at, updated_at
"#,
order_id
)
.fetch_one(&mut *tx)
.await?;
// Step 5: Audit log với diff status
audit::log_action(
&mut tx,
"orders",
order_id,
"cancel",
actor_user_id,
Some(serde_json::json!({
"from_status": current.status,
"to_status": "cancelled",
})),
request_id,
)
.await?;
tx.commit().await?;
Ok(order)
}
Mở rộng OrderError enum (B54 continued) thêm variant InvalidTransition:
// File: crates/shop-db/src/orders.rs
#[derive(Debug, thiserror::Error)]
pub enum OrderError {
#[error(transparent)]
Sqlx(#[from] sqlx::Error),
#[error("order {0} not found")]
NotFound(i64),
#[error("invalid state transition from {from} to {to}")]
InvalidTransition { from: String, to: String },
#[error("product {0} not found")]
ProductNotFound(i64),
#[error("insufficient stock for product {product_id}: requested {requested}, available {available}")]
InsufficientStock {
product_id: i64,
requested: i32,
available: i32,
},
}
Handler HTTP wire endpoint POST /api/v1/orders/{id}/cancel — action verb pattern B61 continued, action thay vì soft delete trực tiếp:
// File: crates/shop-api/src/routes/orders.rs
use axum::{
extract::{Path, State},
Json,
};
use shop_db::orders;
use crate::{state::AppState, error::AppError};
pub async fn cancel_order(
State(state): State<AppState>,
Path(order_id): Path<i64>,
) -> Result<Json<OrderResponseDto>, AppError> {
let order = orders::cancel_order(&state.db, order_id, None, None).await?;
Ok(Json(OrderResponseDto::from(order)))
}
FOR UPDATE row lock MANDATORY ở step 1 vì 2 admin có thể cùng gọi cancel với cùng order_id đồng thời. Không có lock: cả 2 đọc status = 'paid', cả 2 validate pass, cả 2 restore stock (stock bị cộng 2 lần dư!), cả 2 update status (vẫn idempotent), nhưng audit log ghi 2 lần. Có FOR UPDATE: transaction thứ 2 block tại SELECT đến khi transaction thứ 1 COMMIT, transaction thứ 2 đọc thấy status = 'cancelled' → InvalidTransition reject. Pattern lock vĩnh viễn cho mọi state transition Shop API.
update_parent Categories — Cycle Detection Production
B63 đã preview cycle detection cho move category nhưng chưa implement đầy đủ. Vấn đề cụ thể: cây categories adjacency + materialized path, nếu admin move category A vào subtree của chính nó → cycle A → B → C → A, materialized path infinite loop khi update descendant.
Cycle detection MANDATORY qua WITH RECURSIVE EXISTS subquery — duyệt subtree của category đang move, kiểm tra new_parent_id có nằm trong subtree không:
// File: crates/shop-db/src/categories.rs
use sqlx::PgPool;
use crate::audit;
pub async fn update_parent(
pool: &PgPool,
category_id: i64,
new_parent_id: Option<i64>,
actor_user_id: Option<i64>,
request_id: Option<&str>,
) -> Result<CategoryRow, CategoryError> {
let mut tx = pool.begin().await?;
// Step 1: Get current category (active only)
let current = sqlx::query!(
r#"SELECT id, path, depth FROM categories
WHERE id = $1 AND deleted_at IS NULL"#,
category_id
)
.fetch_optional(&mut *tx)
.await?
.ok_or(CategoryError::NotFound(category_id))?;
// Step 2: Cycle detection — new_parent KHÔNG được là descendant của current
if let Some(npid) = new_parent_id {
let is_descendant: Option<bool> = sqlx::query_scalar!(
r#"
WITH RECURSIVE subtree AS (
SELECT id FROM categories WHERE id = $1
UNION ALL
SELECT c.id FROM categories c
INNER JOIN subtree s ON c.parent_id = s.id
WHERE c.deleted_at IS NULL
)
SELECT EXISTS(SELECT 1 FROM subtree WHERE id = $2)
"#,
category_id,
npid
)
.fetch_one(&mut *tx)
.await?;
if is_descendant.unwrap_or(false) {
tx.rollback().await?;
return Err(CategoryError::CycleDetected {
category_id,
new_parent_id: npid,
});
}
}
// Step 3: Get new parent path + depth (hoặc root nếu None)
let (new_path_prefix, new_depth) = if let Some(npid) = new_parent_id {
let p = sqlx::query!(
r#"SELECT path, depth FROM categories
WHERE id = $1 AND deleted_at IS NULL"#,
npid
)
.fetch_optional(&mut *tx)
.await?
.ok_or(CategoryError::ParentNotFound(npid))?;
(format!("{}.{}", p.path, category_id), p.depth + 1)
} else {
(category_id.to_string(), 0)
};
let old_path = current.path.clone();
// Step 4a: Update current node
sqlx::query!(
"UPDATE categories SET parent_id = $1, path = $2, depth = $3 WHERE id = $4",
new_parent_id,
new_path_prefix,
new_depth,
category_id,
)
.execute(&mut *tx)
.await?;
// Step 4b: Update descendants — replace old_path prefix bằng new_path_prefix
sqlx::query!(
r#"
UPDATE categories
SET path = $1 || SUBSTRING(path FROM LENGTH($2) + 1),
depth = depth + ($3 - $4)
WHERE path LIKE $2 || '.%' AND deleted_at IS NULL
"#,
new_path_prefix,
old_path,
new_depth,
current.depth,
)
.execute(&mut *tx)
.await?;
// Step 5: Audit log
audit::log_action(
&mut tx,
"categories",
category_id,
"update_parent",
actor_user_id,
Some(serde_json::json!({
"new_parent_id": new_parent_id,
"old_path": old_path,
"new_path": new_path_prefix,
})),
request_id,
)
.await?;
let updated = sqlx::query_as!(
CategoryRow,
r#"SELECT id, parent_id, name, slug, path, depth, display_order,
deleted_at, created_at, updated_at
FROM categories WHERE id = $1"#,
category_id
)
.fetch_one(&mut *tx)
.await?;
tx.commit().await?;
Ok(updated)
}
CategoryError enum mới — định nghĩa cuối cùng (B63 đã preview, B65 hoàn thành):
// File: crates/shop-db/src/categories.rs
#[derive(Debug, thiserror::Error)]
pub enum CategoryError {
#[error(transparent)]
Sqlx(#[from] sqlx::Error),
#[error("category {0} not found")]
NotFound(i64),
#[error("parent category {0} not found")]
ParentNotFound(i64),
#[error("cycle detected: category {category_id} cannot have parent {new_parent_id} (descendant)")]
CycleDetected { category_id: i64, new_parent_id: i64 },
}
Mapping CategoryError sang AppError trong shop-common::error:
// File: crates/shop-common/src/error.rs
use shop_db::categories::CategoryError;
impl From<CategoryError> for AppError {
fn from(err: CategoryError) -> Self {
match err {
CategoryError::NotFound(id) => AppError::NotFound {
resource: "category".into(),
id: id.to_string(),
},
CategoryError::ParentNotFound(id) => AppError::ValidationFailed {
field: "parent_id".into(),
message: format!("parent category {id} not found"),
},
CategoryError::CycleDetected { category_id, new_parent_id } => {
AppError::ValidationFailed {
field: "parent_id".into(),
message: format!(
"moving category {category_id} under {new_parent_id} would create a cycle"
),
}
}
CategoryError::Sqlx(e) => AppError::from(e),
}
}
}
Endpoint wire PATCH /api/v1/admin/categories/{slug}/parent với DTO chỉ chứa 1 field:
// File: crates/shop-common/src/dto/category.rs
use serde::Deserialize;
#[derive(Debug, Deserialize)]
pub struct UpdateParentDto {
pub parent_id: Option<i64>, // None = move thành root
}
WITH RECURSIVE EXISTS bound terminate vì subtree finite (tree adjacency + cycle detection chính câu query này), Postgres planner thực thi recursive CTE đến khi không còn row mới thêm. Performance: subtree N node → query O(N) row scan, với cây Shop API ~3-4 depth × 50 node/level ~200 node max → <5ms thực tế. Nếu cây sâu hơn 10+ depth, cần index bổ sung CREATE INDEX categories_parent_id_idx ON categories(parent_id) WHERE deleted_at IS NULL.
Bulk Restore Endpoint
Use case thực tế: admin UI "Trash" hiển thị product đã soft-deleted, cho phép bulk select 5-20 item rồi click "Restore". Endpoint POST /api/v1/admin/products/restore-bulk — namespace /admin/ sẽ guard auth elevated ở B112.
DTO với cap 100 slug/request bằng validator::Validate:
// File: crates/shop-common/src/dto/product.rs
use serde::{Deserialize, Serialize};
use validator::Validate;
#[derive(Debug, Deserialize, Validate)]
pub struct BulkRestoreDto {
#[validate(length(min = 1, max = 100, message = "1-100 slug mỗi request"))]
pub slugs: Vec<String>,
}
#[derive(Debug, Serialize)]
pub struct BulkRestoreResponse {
pub requested: u32,
pub restored_count: u32,
pub restored_slugs: Vec<String>,
}
Function bulk restore trong shop-db dùng = ANY($1) + RETURNING slug 1 statement:
// File: crates/shop-db/src/products.rs
pub async fn bulk_restore_products(
pool: &PgPool,
slugs: &[String],
actor_user_id: Option<i64>,
request_id: Option<&str>,
) -> Result<Vec<String>, sqlx::Error> {
let mut tx = pool.begin().await?;
let restored: Vec<String> = sqlx::query_scalar!(
r#"
UPDATE products SET deleted_at = NULL
WHERE slug = ANY($1) AND deleted_at IS NOT NULL
RETURNING slug
"#,
slugs
)
.fetch_all(&mut *tx)
.await?;
// Audit log bulk action — row_id = 0 đánh dấu bulk
audit::log_action(
&mut tx,
"products",
0,
"bulk_restore",
actor_user_id,
Some(serde_json::json!({
"requested": slugs,
"restored": restored,
"count": restored.len(),
})),
request_id,
)
.await?;
tx.commit().await?;
Ok(restored)
}
Handler HTTP:
// File: crates/shop-api/src/routes/products.rs
use crate::extractors::ValidatedJson;
use axum::{extract::State, Extension, Json};
use shop_common::dto::{BulkRestoreDto, BulkRestoreResponse};
pub async fn bulk_restore_products(
State(state): State<AppState>,
Extension(req_id): Extension<RequestId>,
ValidatedJson(dto): ValidatedJson<BulkRestoreDto>,
) -> Result<Json<BulkRestoreResponse>, AppError> {
let restored = shop_db::products::bulk_restore_products(
&state.db,
&dto.slugs,
None,
Some(&req_id.0),
)
.await?;
Ok(Json(BulkRestoreResponse {
requested: dto.slugs.len() as u32,
restored_count: restored.len() as u32,
restored_slugs: restored,
}))
}
Test bằng curl:
curl -X POST http://localhost:3000/api/v1/admin/products/restore-bulk \
-H 'Content-Type: application/json' \
-d '{
"slugs": ["iphone-15-pro", "macbook-air-m3", "ipad-pro"]
}'
# Response:
# {
# "requested": 3,
# "restored_count": 2,
# "restored_slugs": ["iphone-15-pro", "macbook-air-m3"]
# }
# — slug "ipad-pro" không restore vì không soft-deleted (đã active)
Audit log với row_id = 0 là convention đánh dấu bulk action — không phải 1 product cụ thể mà là tập hợp. Khi filter audit log ?row_id=0 AND action=bulk_restore, admin xem được lịch sử mọi bulk operation. Cap 100 slug/request lock vì payload + transaction time scale linear theo size; 100 đủ cho UI bulk select page, cao hơn nên chia nhiều request.
GET /admin/audit-logs Filter Endpoint
Use case: admin xem audit trail filter theo table (mọi mutation products), action (cancel orders gần đây), actor (action của 1 admin cụ thể), hoặc request_id (trace 1 request xuyên 5 mutation cùng correlation id).
DTO query với pagination + 4 filter optional:
// File: crates/shop-common/src/dto/audit.rs
use serde::{Deserialize, Serialize};
use validator::Validate;
#[derive(Debug, Deserialize, Validate)]
pub struct AuditLogQuery {
#[serde(default)]
pub table_name: Option<String>,
#[serde(default)]
pub action: Option<String>,
#[serde(default)]
pub actor_user_id: Option<i64>,
#[serde(default)]
pub request_id: Option<String>,
#[serde(default = "default_page")]
pub page: u32,
#[serde(default = "default_per_page")]
#[validate(range(min = 1, max = 100))]
pub per_page: u32,
}
fn default_page() -> u32 { 1 }
fn default_per_page() -> u32 { 20 }
#[derive(Debug, Serialize)]
pub struct AuditLogResponseDto {
pub id: i64,
pub table_name: String,
pub row_id: i64,
pub action: String,
pub actor_user_id: Option<i64>,
pub changes: Option<serde_json::Value>,
pub request_id: Option<String>,
pub created_at: chrono::DateTime<chrono::Utc>,
}
Function shop-db dùng QueryBuilder dynamic SQL (B59 pattern continued) — conditional WHERE clauses:
// File: crates/shop-db/src/audit.rs
use sqlx::{PgPool, Postgres, QueryBuilder};
#[derive(Debug, sqlx::FromRow)]
pub struct AuditLogRow {
pub id: i64,
pub table_name: String,
pub row_id: i64,
pub action: String,
pub actor_user_id: Option<i64>,
pub changes: Option<serde_json::Value>,
pub request_id: Option<String>,
pub created_at: chrono::DateTime<chrono::Utc>,
}
pub async fn list_audit_logs(
pool: &PgPool,
filter: &AuditLogQuery,
) -> Result<(Vec<AuditLogRow>, u64), sqlx::Error> {
let mut qb: QueryBuilder<Postgres> = QueryBuilder::new(
"SELECT id, table_name, row_id, action, actor_user_id, \
changes, request_id, created_at FROM audit_logs WHERE 1=1"
);
if let Some(table) = &filter.table_name {
qb.push(" AND table_name = ").push_bind(table.clone());
}
if let Some(action) = &filter.action {
qb.push(" AND action = ").push_bind(action.clone());
}
if let Some(actor) = filter.actor_user_id {
qb.push(" AND actor_user_id = ").push_bind(actor);
}
if let Some(rid) = &filter.request_id {
qb.push(" AND request_id = ").push_bind(rid.clone());
}
qb.push(" ORDER BY created_at DESC");
qb.push(" LIMIT ").push_bind(filter.per_page as i64);
qb.push(" OFFSET ").push_bind(((filter.page - 1) * filter.per_page) as i64);
let rows = qb.build_query_as::<AuditLogRow>().fetch_all(pool).await?;
// Count query song song (giản lược — production dùng cùng filter builder)
let total: i64 = sqlx::query_scalar!("SELECT COUNT(*) FROM audit_logs")
.fetch_one(pool)
.await?
.unwrap_or(0);
Ok((rows, total as u64))
}
Handler HTTP wire route trong module mới routes/admin.rs:
// File: crates/shop-api/src/routes/admin.rs
use axum::{
extract::{Query, State},
routing::get,
Json, Router,
};
use shop_common::dto::{AuditLogQuery, AuditLogResponseDto};
use crate::{state::AppState, error::AppError};
pub async fn list_audit_logs(
State(state): State<AppState>,
Query(filter): Query<AuditLogQuery>,
) -> Result<Json<serde_json::Value>, AppError> {
let (rows, total) = shop_db::audit::list_audit_logs(&state.db, &filter).await?;
let items: Vec<AuditLogResponseDto> = rows.into_iter()
.map(AuditLogResponseDto::from)
.collect();
Ok(Json(serde_json::json!({
"items": items,
"total": total,
"page": filter.page,
"per_page": filter.per_page,
})))
}
pub fn routes() -> Router<AppState> {
Router::new()
.route("/admin/audit-logs", get(list_audit_logs))
}
Test bằng curl 3 case:
# Filter theo table
curl 'http://localhost:3000/api/v1/admin/audit-logs?table_name=products&per_page=10'
# Filter theo request_id (trace 1 request xuyên nhiều mutation)
curl 'http://localhost:3000/api/v1/admin/audit-logs?request_id=550e8400-e29b-41d4-a716-446655440000'
# Filter theo actor + action (admin nào đã cancel order nào)
curl 'http://localhost:3000/api/v1/admin/audit-logs?actor_user_id=1&action=cancel'
Pattern QueryBuilder + push_bind bảo vệ SQL injection vì binding vẫn qua prepared statement underlying. KHÔNG bao giờ dùng format!() chèn user input vào câu query.
Storage Cost Audit Trail — Trade-Off Discussion
Audit log mỗi mutation → bảng audit_logs growth nhanh nhất trong toàn DB Shop API. Phép tính thực tế cho shop quy mô vừa:
10K order/day × 5 mutation/order (create + paid + shipped + delivered + items)
= 50K row audit/day
× 365 day = 18.25M row/year
× ~500 byte/row (id + table_name + row_id + action + actor + JSONB changes + request_id + created_at)
≈ 9 GB raw data + ~3 GB index = 12 GB/year
4 mitigation strategy theo độ phức tạp tăng dần:
- Partitioning theo tháng qua Postgres
PARTITION BY RANGE (created_at)— bảng chaaudit_logs+ bảng conaudit_logs_2026_06,audit_logs_2026_07tạo tự động quapg_partmanextension hoặc cron job. Pros: query gần đây chỉ scan 1 partition mỏng (~750MB/tháng), DROP partition cũ tức thì thay DELETE chậm. Cons: planner cần partition pruning đúng (PG 11+ tốt), complex hơn. - Archive older 90 ngày → cold storage S3 Glacier hoặc cloud equivalent → vacuum DB sau archive. Pros: chi phí storage giảm 10× (S3 Glacier ~$0.004/GB/tháng so với managed Postgres ~$0.10/GB/tháng), DB hot subset nhỏ. Cons: query log cũ chậm (phải restore từ S3), workflow phức tạp.
- Compress JSONB — Postgres TOAST tự động compress payload > 2KB. Pros: zero config. Cons: row nhỏ < 2KB không compress, không giúp với audit Shop API (JSONB diff thường nhỏ).
- Sample rate — chỉ log mutation quan trọng (CRUD products/orders/users, payment events), skip read endpoint. Pros: giảm row 80-90%. Cons: mất visibility cho fraud detection, compliance audit thiếu.
Lock decision Shop API 3 phase theo growth thực tế:
- Phase 1 (B65 → G17): single table không partition, retention forever. Khi DB < 50GB tổng, complexity thêm partition không worth. Index
audit_created_at_idx+audit_request_id_idxđủ cho query < 100ms p95. - Phase 2 (G18 deploy production): monthly partition + 90-day hot retention. Khi audit_logs > 10GB hoặc > 100M row, partition giảm query latency + cho phép DROP partition cũ tức thời.
- Phase 3 (G18+ scale): archive cold storage S3 Glacier monthly job, giữ 90 ngày hot trong DB + 7 năm archive cho compliance kế toán Việt Nam.
GDPR Article 17 right-to-erasure xung đột audit trail — preview G19: khi user request xóa data, audit log có actor_user_id hoặc changes.user_email phải anonymize (replace user_id bằng 0, redact PII trong JSONB). Pattern: retention exception cho audit log dưới điều khoản "legal obligation" (Article 17(3)(b)) khi liên quan đơn hàng/thanh toán, nhưng PII trong free-text field vẫn phải erase.
Tổng Kết
- 3 model soft delete: timestamp
deleted_atcho products + categories + users, statestatusenum cho orders cancel transition, combined (state + deleted_at) cho users với GDPR right-to-erasure. - users table mới với 4-state lifecycle (active/suspended/deactivated) +
deleted_atGDPR Article 17. - Migration 9
create_users+ ALTER FKorders.user_id → users(id) ON DELETE RESTRICThoàn thành preview B54. cancel_orderstate machine 5 step:FOR UPDATErow lock → validate transition match arm → restore stock UPDATE products FROM order_items → UPDATE orders status → audit log diff.- State transition table orders: pending→paid→shipped→delivered hợp lệ; pending|paid → cancelled OK; shipped|delivered → cancelled REJECT (dùng return endpoint B68).
update_parentcycle detection quaWITH RECURSIVE EXISTSsubquery — defend pattern A → B → A loop.- Update descendant path qua
UPDATE ... SET path = $new_prefix || SUBSTRING(path FROM LENGTH($old_path) + 1)+ depth recompute cho mọi node subtree (materialized path B63 lock continued). CategoryErrorenum: NotFound + ParentNotFound + CycleDetected + Sqlx 4 variant +impl From<CategoryError> for AppErrormap 404/422.- Bulk restore endpoint
POST /admin/products/restore-bulkvớiBulkRestoreDtovalidate min=1 max=100 slug/request, RETURNING slug, audit row_id 0 bulk marker. - Audit log filter endpoint
GET /admin/audit-logsvớiQueryBuilderdynamic SQL + 4 filter (table_name/action/actor_user_id/request_id) + pagination. - Storage cost trade-off roadmap: phase 1 single table → phase 2 monthly partition G18 → phase 3 cold archive S3 G18+.
- File path lock: extend
crates/shop-db/src/orders.rs(cancel_order),categories.rs(update_parent + CategoryError),products.rs(bulk_restore_products),audit.rs(list_audit_logs + AuditLogRow); NEWusers.rsskeleton; migration20260615180000_create_users.sql; NEWcrates/shop-api/src/routes/admin.rs.
Bài Tập Củng Cố
Tự trả lời, đáp án ở cuối:
- 3 model soft delete khác nhau ra sao (timestamp/state/combined)? Tại sao orders chọn state thay
deleted_atnhư products? - State machine
cancel_order5 step — bước nào MANDATORY restore stock và tại sao đặt trước UPDATE status? PatternFOR UPDATEchống race condition cụ thể nào? - Cycle detection update parent —
WITH RECURSIVE EXISTSpattern hoạt động ra sao trong câu query? Cho ví dụ cycle scenario A → B → C → A và kết quả query. - Bulk restore endpoint cap 100 slug/request — lý do? Trade-off với UI admin cần bulk select 1000 item?
- Audit log storage cost — 3 mitigation strategies (partition/archive/sample) là gì? Trade-off mỗi cái với requirement compliance kế toán Việt Nam giữ 10 năm?
Đáp án
- 3 model soft delete khác biệt + orders chọn state: Timestamp-based dùng cột
deleted_at TIMESTAMPTZ NULL— NULL = active, có giá trị = đã xóa, đơn giản, restore SET NULL, partial indexWHERE deleted_at IS NULLgiữ hot subset nhanh. Áp dụng products + categories. State-based dùng cộtstatus TEXTvới CHECK constraint danh sách enum hợp lệ — không có khái niệm "xóa" trực tiếp, mà chuyển sang state terminal (cancelled cho orders, archived cho posts). Rich semantic phân biệt cancelled vs refunded vs returned. Combined dùng cả 2 trên cùng bảng — users cóstatus(active/suspended/deactivated) cho UX lifecycle +deleted_atriêng cho GDPR hard erase. Tại sao orders chọn state thay deleted_at: 4 lý do cụ thể. (a) Giá trị tài chính — orders mang doanh thu, KHÔNG bao giờ xóa khỏi DB vì luật kế toán Việt Nam giữ chứng từ 10 năm (Luật Kế toán 2015 điều 41). Soft delete pattern "ẩn khỏi list active" không đúng semantic — đơn hủy vẫn cần hiển thị trong báo cáo doanh thu, chỉ không tính vào revenue confirmed. (b) Multi-state thay 2-state: orders có 5 trạng thái phân biệt rõ (pending/paid/shipped/delivered/cancelled), chứ không phải 2 trạng thái (active/deleted).deleted_atkhông biểu đạt được "đã shipped" vs "đã delivered" — phải có cột riêng. (c) Transition validation: state machine cưỡng chế "pending → paid OK", "shipped → cancelled REJECT" qua code. Timestamp không có khái niệm transition validation. (d) Audit trail: status transition tự nó là audit — queryWHERE status_at_time = 'paid'trả mọi đơn đã được paid trong khoảng thời gian, không cần audit log riêng. Generalize: resource có giá trị tài chính + multi-stage lifecycle → state-based. Resource là content user-generated + 2-state (active/trashed) → timestamp-based. Resource cần GDPR compliance + lifecycle mở rộng → combined. - cancel_order 5 step + restore stock + FOR UPDATE race: 5 step: (1)
SELECT ... FOR UPDATEget current status với row lock; (2) match arm validate transition pending|paid OK, shipped|delivered|cancelled REJECT; (3) restore stock UPDATE products FROM order_items; (4) UPDATE orders SET status='cancelled' RETURNING; (5) audit log diff. Restore stock MANDATORY: order_items đã ghiquantitymỗi product khi tạo đơn (B54 lock create_order_atomic), đồng thời decrementproducts.stocktương ứng. Khi cancel, phải cộng lại stock — nếu không, inventory bị "rò rỉ" mỗi cancel: stock giảm vĩnh viễn mà không có giá trị business tương ứng. Tại sao đặt trước UPDATE status: thứ tự không ảnh hưởng correctness vì cùng transaction atomic, nhưng đặt trước là defensive — nếu step 4 fail (DB constraint violation rare case), transaction rollback, không bị state inconsistent "status cancelled nhưng stock chưa restore". Nguyên tắc chung: side effect critical trước state change visible. FOR UPDATE chống race cụ thể: 2 admin (hoặc 2 instance shop-api scale horizontal) cùng gọiPOST /orders/{id}/cancelvới cùng order_id tại cùng millisecond. Không có FOR UPDATE: TX1 đọcstatus='paid', TX2 đọcstatus='paid'cùng lúc; cả 2 validate pass; cả 2 restore stock (stock cộng 2 lần dư, tổng = base + 2 × qty); cả 2 UPDATE status='cancelled' (idempotent, OK); cả 2 audit log (2 row audit cho cùng 1 cancel, confusion). Có FOR UPDATE: TX2 SELECT block tại row lock đến khi TX1 COMMIT; TX2 đọcstatus='cancelled'; TX2 match arm cancelled → InvalidTransition error → rollback. Stock chỉ restore 1 lần, audit log 1 row, correctness đảm bảo. Lock Shop API: mọi state transition MANDATORYSELECT ... FOR UPDATEpattern — cancel order, paid order, ship order, deliver order (B68 implement đầy đủ). Generalize: bất kỳ mutation phụ thuộc current state phải pessimistic lock row trước validate transition. - WITH RECURSIVE EXISTS cycle detection + ví dụ A→B→C→A: Cấu trúc query:
WITH RECURSIVE subtree AS (anchor + recursive part)Postgres pattern duyệt cây quan hệ self-reference. AnchorSELECT id FROM categories WHERE id = $1seed với category đang move (gọi là X). Recursive partSELECT c.id FROM categories c INNER JOIN subtree s ON c.parent_id = s.id WHERE c.deleted_at IS NULL— mỗi vòng lặp tìm thêm con của các node đã trong subtree, JOIN với UNION ALL gom kết quả. Postgres tự terminate khi vòng lặp không thêm row mới. Cuối cùngSELECT EXISTS(SELECT 1 FROM subtree WHERE id = $2)kiểm tra new_parent_id có nằm trong subtree X không — nếu có, move tạo cycle. Ví dụ cycle scenario A → B → C → A: setup cây hiện tại Electronics (id=1) → Smartphones (id=2) → Apple (id=3). User request move Electronics (id=1) thành con của Apple (id=3) — nghĩa là chain mới: ... → Apple → Electronics → Smartphones → Apple → ... infinite loop. Query với category_id=1, new_parent_id=3: anchorSELECT id WHERE id=1trả {1}. Vòng 1:SELECT c.id FROM categories c JOIN subtree ON c.parent_id = s.idvới subtree={1}, tìm con của 1 → {2} (Smartphones). Subtree={1, 2}. Vòng 2: tìm con của 2 → {3} (Apple). Subtree={1, 2, 3}. Vòng 3: tìm con của 3 → rỗng (Apple là leaf hiện tại). Terminate. EXISTS check id=3 (new_parent_id) trong subtree={1, 2, 3} → TRUE → CycleDetected error 422. Move hợp lệ ví dụ: move Apple (id=3) thành con của một node ngoài subtree, ví dụ Laptops (id=4). Anchor={3}, vòng 1 không con → terminate, subtree={3}, EXISTS id=4 → FALSE → cycle check pass. Performance: cây Shop API ~3-4 depth × 50 node/level ~200 node max, recursive query <5ms. Lock Shop API: cycle detection MANDATORY trước update parent — KHÔNG cho user-facing API skip check. Generalize: mọi self-referential tree (categories, comments threads, file folders, organization hierarchy) cần WITH RECURSIVE EXISTS trước move/reparent operation. - Cap 100 slug bulk restore + trade-off UI 1000 item: 3 lý do cap 100: (a) Payload size: 100 slug × ~30 byte/slug = 3KB JSON request body — well below proxy/gateway limits (Nginx default 1MB, Cloudflare 100MB), không bị reject. 1000 slug → 30KB OK technically nhưng border line nếu kèm path/query. (b) Transaction time: UPDATE batch với
= ANY($1)scale linear theo size — 100 row ~5-10ms thực tế, 1000 row ~50-100ms, 10K row ~500ms-1s. Transaction quá dài hold lock dài, block concurrent read, increase tail latency. (c) Audit log payload:changes.requestedJSONB lưu cả 1000 slug + restored list × 2 = ~60KB/row audit log → bloat DB nhanh, JSONB TOAST overhead. Trade-off UI admin 1000 item: 2 pattern UX. (a) Client-side chunking — UI nhận 1000 selection, JS gom thành 10 request × 100 slug, gửi parallel hoặc sequential, hiển thị progress bar. Pros: server giữ contract 100/request rõ ràng. Cons: client phức tạp hơn, partial failure khó handle (3/10 batch fail thì state UI mơ hồ). (b) Async job queue — endpointPOST /admin/products/restore-bulk-asyncnhận N slug không limit, push job vào apalis queue (B71+ Redis), trả 202 Accepted + job_id ngay; worker xử lý background, UI pollGET /jobs/{id}hoặc subscribe WebSocket. Pros: scale tốt cho 10K+, không block UI. Cons: infrastructure phức tạp, eventual consistency. Lock Shop API B65: phase 1 cap 100 sync endpoint đủ cho admin UI vừa. Phase 2 (G16+) implement async bulk endpoint cho dataset lớn. Generalize: mọi bulk endpoint sync cap theo công thức "p95 latency target / per-item processing time" — Shop API target p95 200ms × per-item 2ms = 100 item. - 3 mitigation storage cost + trade-off retention 10 năm: (1) Partitioning theo tháng — Postgres
PARTITION BY RANGE (created_at), mỗi tháng 1 partition conaudit_logs_2026_06, etc. Tự động tạo qua pg_partman extension hoặc cron job. Pros: query gần đây scan partition mỏng (~750MB/tháng) thay full table (10+ GB), DROP partition cũ tức thì (millisecond) so với DELETE chậm (scan + delete row + index update). Cons: complexity setup + monitoring, planner cần partition pruning đúng (PG 11+ tốt), index riêng từng partition. (2) Archive 90 ngày + cold storage S3 — monthly jobCOPY audit_logs_2026_06 TO 's3://...'rồi DROP partition. Pros: chi phí storage S3 Glacier ~$0.004/GB/tháng so với managed Postgres ~$0.10/GB/tháng (25× rẻ hơn), DB hot subset chỉ <5GB. Cons: query log cũ chậm (restore từ S3 mất phút đến giờ), workflow phức tạp + cần infrastructure separate (S3 + Athena query). (3) Sample rate — chỉ log mutation quan trọng (CRUD products/orders/users/payments), skip read endpoint + low-value mutation (search log, pageview). Pros: giảm row volume 80-90%. Cons: mất visibility cho fraud detection (cần log đăng nhập failed), compliance audit thiếu (GDPR audit access request không có log). Trade-off retention 10 năm Việt Nam: Luật Kế toán 2015 điều 41 buộc giữ chứng từ kế toán 10 năm — bao gồm log thanh toán + đơn hàng (mặc dù audit log không phải chứng từ trực tiếp, nhưng forensic compliance cần). Lock Shop API roadmap: (a) Phase 1 (B65 → G17) single table không partition, retention forever, accept storage cost. Khi DB tổng < 50GB, complexity partition không worth. (b) Phase 2 (G18 deploy production): monthly partition + 90-day hot retention trong managed Postgres + 7 năm archive S3 Glacier — kết hợp 1 + 2. Query log gần đây nhanh, log cũ chậm nhưng OK cho audit ad-hoc. (c) Phase 3 (G18+ scale): thêm sample rate (3) cho high-volume endpoint không critical. Compliance: PII trong audit log (user_id, email) phải anonymize theo GDPR Article 17(3)(b) "legal obligation" exception — replace user_id bằng 0 cho user đã exercise right-to-erasure, audit row vẫn giữ nhưng PII redact. Pattern lock vĩnh viễn cho Shop API + áp dụng cho mọi audit table tương lai (login_logs, payment_logs, admin_action_logs G19).
Bài Tiếp Theo
Bài 66: POST /orders Endpoint Hoàn Chỉnh — Idempotency + Retry — implement POST /api/v1/orders endpoint dùng create_order_atomic B54, Idempotency-Key header (Stripe-style) lưu Redis 24h tránh double-charge, retry strategy cho SerializationFailure (B55 with_retry), CreateOrderDto validation, response Created<OrderResponseDto> với Location header.
