Appearance
🏗️ KIẾN TRÚC CORE BE — PHASE 0 (GO MONOLITH)
Scope: Chỉ phần Backend (Go) · Frontend & Crawler ở repo/branch khác Nguyên tắc: Layered monolith — tách rõ HTTP / Service / Repository để sau này tách binary không phải đập đi xây lại.
MỤC LỤC
- 0 · Tổng quan & Tech stack
- 1 · Kiến trúc Layer
- 2 · Project Structure
- 3 · API Endpoints
- 4 · Database Schema
- 5 · Business Flows
- 6 · Quyết định then chốt
- 7 · Khi nào cần đổi
0 · TỔNG QUAN & TECH STACK
Core BE chịu trách nhiệm toàn bộ nghiệp vụ: auth, campaign, order, payment, settlement. Frontend gọi qua REST.
| Hạng mục | Chọn | Lý do |
|---|---|---|
| Runtime | Go 1.22+ | Học Go · binary duy nhất · goroutine cho cron/SSE |
| HTTP Router | chi | Gần net/http chuẩn, dễ học, middleware chain rõ ràng |
| DB Driver | pgx/v5 | Raw pool, không ORM cho hot path — kiểm soát SQL hoàn toàn |
| Codegen | sqlc | Viết SQL → sinh Go typed code. Không mất kiểm soát query |
| Auth | golang-jwt/jwt/v5 | JWT access + refresh, không cần service ngoài |
| Config | godotenv + os.Getenv | Đơn giản, đủ cho Phase 0 |
| Migration | golang-migrate | SQL thuần, versioned, chạy qua CLI |
| Validation | go-playground/validator | Struct tag validation, phổ biến nhất |
| Logging | log/slog (stdlib) | JSON structured, không cần lib ngoài |
| Test | testing + testify + httptest | Chuẩn Go |
Không dùng ở Phase 0
| Lý do chưa dùng | |
|---|---|
| ORM (GORM/Ent) | Mất kiểm soát SQL hot path, học sqlc đủ |
| Redis | Chưa cần cache/queue — in-process goroutine đủ |
| gRPC/Protobuf | Chưa tách service, REST là đủ |
| Framework full (Echo/Fiber/Gin) | chi nhẹ hơn, gần stdlib hơn cho người học |
1 · KIẾN TRÚC LAYER
┌─────────────────────────────────────────────────────────────┐
│ CLIENT (Frontend / Webhook provider) │
└──────────────────────┬──────────────────────────────────────┘
│ HTTP/S + JSON
▼
┌─────────────────────────────────────────────────────────────┐
│ ① HTTP LAYER │
│ ┌─ chi Router │
│ ├─ Middleware: RequestID → Logging → CORS → Recover │
│ ├─ Middleware: Auth (JWT) → RequireAdmin │
│ └─ Handlers: parse request → validate → gọi service │
│ → trả JSON response │
└──────────────────────┬──────────────────────────────────────┘
│
┌──────────────────────▼──────────────────────────────────────┐
│ ② SERVICE LAYER (business logic — KHÔNG biết HTTP/SQL) │
│ ├─ AuthService (login, register, refresh, JWT sign) │
│ ├─ CampaignService (CRUD, open/close, tier management) │
│ ├─ OrderService (reserve slot, anti-oversell) │
│ ├─ PaymentService (webhook verify, idempotent) │
│ ├─ SettlementService (lock tier, calc amount_due) │
│ └─ ExpiryService (cron huỷ đơn quá hạn) │
└──────────────────────┬──────────────────────────────────────┘
│ interface (để mock khi test)
┌──────────────────────▼──────────────────────────────────────┐
│ ③ REPOSITORY LAYER (sqlc generated + pgx pool) │
│ ├─ UserRepo · CampaignRepo · OrderRepo · PaymentRepo │
│ └─ Chỉ chứa SQL, KHÔNG có logic nghiệp vụ │
└──────────────────────┬──────────────────────────────────────┘
│ SQL (pgx)
┌──────────────────────▼──────────────────────────────────────┐
│ ④ PostgreSQL (Source of Truth duy nhất) │
└─────────────────────────────────────────────────────────────┘
═ In-process goroutine (Phase 0) ═
ExpiryService chạy ticker 30s, gọi OrderRepoLuồng 1 request
Request → Middleware chain → Handler
Handler: bind JSON → validate struct
→ gọi Service (truyền ctx + domain struct)
Service: business rules → mở transaction nếu cần
→ gọi Repository (1 hoặc nhiều query)
Service: return domain result / error
Handler: map error → HTTP status + JSON
Response ← JSONQuy tắc: Handler KHÔNG viết SQL. Service KHÔNG import
net/http. Repository KHÔNG cóifnghiệp vụ. Mỗi layer chỉ biết layer ngay dưới nó.
2 · PROJECT STRUCTURE
gghut-core/
├── cmd/
│ └── api/
│ └── main.go # entrypoint: load config → connect DB → run server
│
├── internal/
│ ├── config/ # load .env + validate
│ │ └── config.go
│ │
│ ├── server/ # HTTP wiring
│ │ ├── server.go # chi router, mount routes
│ │ ├── middleware/
│ │ │ ├── auth.go # parse JWT → inject user vào ctx
│ │ │ ├── require_admin.go
│ │ │ ├── logging.go
│ │ │ └── recover.go
│ │ └── handler/ # 1 file per domain
│ │ ├── auth.go
│ │ ├── campaign.go
│ │ ├── order.go
│ │ ├── payment_webhook.go
│ │ └── health.go
│ │
│ ├── service/ # business logic
│ │ ├── auth.go
│ │ ├── campaign.go
│ │ ├── order.go
│ │ ├── payment.go
│ │ ├── settlement.go
│ │ └── expiry.go # cron goroutine
│ │
│ ├── repository/ # interfaces + sqlc impl
│ │ ├── user.go
│ │ ├── campaign.go
│ │ ├── order.go
│ │ └── payment.go
│ │
│ ├── model/ # domain structs + errors
│ │ ├── user.go
│ │ ├── campaign.go
│ │ ├── order.go
│ │ ├── payment.go
│ │ └── errors.go # domain errors (ErrSlotFull, ErrCampaignClosed...)
│ │
│ └── pkg/ # shared utilities
│ ├── jwt/ # sign/verify token
│ ├── response/ # helper viết JSON response
│ ├── ordercode/ # sinh mã đơn "GB-XXXX"
│ ├── r2/ # Cloudflare R2 client (S3-compatible)
│ └── telemetry/ # tracing + logs qua OpenTelemetry (Grafana Cloud)
│
├── db/
│ ├── migrations/ # 000001_init.up.sql / .down.sql
│ ├── queries/ # *.sql cho sqlc
│ └── sqlc.yaml # sqlc config
│
├── docs/ # (file này)
├── .env.example
├── go.mod
├── Makefile # make run / make migrate / make sqlc / make test
└── DockerfileVì sao
internal/: Go enforceinternal/không import được từ ngoài — đảm bảo không ai phụ thuộc vào cấu trúc bên trong. Sau này tách binary, move package rapkg/hoặc repo riêng dễ.
3 · API ENDPOINTS
Prefix: /api/v1 · Auth: Authorization: Bearer <JWT>
Auth
| Method | Path | Auth | Mô tả |
|---|---|---|---|
| POST | /auth/register | — | Đăng ký (email + password) |
| POST | /auth/login | — | Login → trả access + refresh token |
| POST | /auth/refresh | refresh | Đổi access token mới |
| GET | /auth/me | user | Thông tin user hiện tại |
Campaign
| Method | Path | Auth | Mô tả |
|---|---|---|---|
| GET | /campaigns | — | List campaign (OPEN/CLOSED/CANCELLED), filter + paginate |
| GET | /campaigns/:slug | — | Chi tiết campaign (kèm tiers, remaining slots, gallery) |
| POST | /admin/campaigns | admin | Tạo campaign (draft) |
| PUT | /admin/campaigns/:id | admin | Sửa (chỉ khi DRAFT) |
| POST | /admin/campaigns/:id/open | admin | Mở bán (DRAFT → OPEN) |
| POST | /admin/campaigns/:id/close | admin | Đóng nhóm (OPEN → CLOSED) |
| POST | /admin/campaigns/:id/cancel | admin | Huỷ campaign |
| POST | /admin/campaigns/:id/banner | admin | Upload ảnh banner (multipart, field image, ≤10MB, jpg/png/webp/gif) — upload mới ghi đè cũ |
| POST | /admin/campaigns/:id/images | admin | Thêm nhiều ảnh vào gallery trong 1 request (multipart, field image lặp lại, tối đa 10 ảnh × 10MB) |
| DELETE | /admin/campaigns/:id/images/:imageID | admin | Xoá 1 ảnh khỏi gallery |
Ảnh campaign lưu trên Cloudflare R2 (bucket
gghut, S3-compatible API, packageinternal/pkg/r2). DB chỉ giữ URL public (banner_url,campaign_images.url). Cấu hình qua envR2_ACCOUNT_ID/R2_ACCESS_KEY_ID/R2_SECRET_ACCESS_KEY/R2_BUCKET/R2_PUBLIC_BASE_URL— không setR2_ACCOUNT_IDthì endpoint upload trả 503.
Tracing (distributed trace): OTel instrument trong
internal/pkg/telemetry— middleware mở span mỗi request (extract W3Ctraceparenttừ FE để nối cùng 1 trace),otelpgxtạo span cho từng query SQL. Xuất OTLP tới Google Cloud Trace (GOOGLE_CLOUD_PROJECT, sample ratioTRACE_SAMPLE_RATIO), xác thực bằng ADC của service account. Chưa set project → otel API chạy noop, không tốn chi phí. FE sau này instrument bằng@opentelemetrylà tự nối user → API → DB.Chi tiết lỗi ngay trên span: tầng
responseghi lỗi nghiệp vụ thật (mã + message) vào writer qua interfaceErrorRecorder.RecordResponseError— middleware flush thành event trên span (exception+app.error_code,app.http_status), nên waterfall trace hiện lý do lỗi thay vì chỉ con số 500. Mỗi response kèm headerX-Trace-Id; mọi log emit bằngslog.XxxContext(ctx,...)tự gắn fieldtrace_id(handler trongtelemetry/log.go) — tra cứu log ↔ trace bằng cùng id. Panic + lỗi 5xx cũng được record vào span (statusError) — error tracking chạy trên cùng stack OTel/Cloud Trace, không cần vendor riêng.
Order
| Method | Path | Auth | Mô tả |
|---|---|---|---|
| POST | /orders | user | Đặt slot (hot path — atomic SQL) |
| GET | /orders | user | Đơn của tôi |
| GET | /orders/:id | owner | Chi tiết đơn (chủ đơn hoặc admin) |
| GET | /admin/orders | admin | Tất cả đơn, filter theo campaign/status |
| GET | /admin/campaigns/:id/orders | admin | Đơn theo campaign |
| POST | /admin/campaigns/:id/settle | admin | Trigger settlement thủ công (Phase 0) |
Payment & Webhook
| Method | Path | Auth | Mô tả |
|---|---|---|---|
| POST | /webhooks/sepay | Apikey header | Webhook biến động số dư từ SePay (verify auth → idempotent) |
System
| Method | Path | Auth | Mô tả |
|---|---|---|---|
| GET | /healthz | — | Liveness (process sống) |
| GET | /readyz | — | Readiness (DB connect được) |
Response format chuẩn
json
// Success
{ "data": { ... }, "meta": { "page": 1, "total": 42 } }
// Error
{ "error": { "code": "SLOT_FULL", "message": "Campaign đã hết slot" } }Quy tắc: Mọi error trả về dùng
error.codemachine-readable để FE xử lý,messagehuman-readable. HTTP status đi kèm (400/401/403/404/409/500).
4 · DATABASE SCHEMA
ERD tổng quan
┌──────────┐ ┌───────────────┐ ┌────────────────┐
│ users │ │ campaigns │ │ campaign_tiers │
├──────────┤ ├───────────────┤ ├────────────────┤
│ id │ │ id │◄───────┤ campaign_id │
│ email │ │ title, slug │ 1:N │ qty_threshold │
│ pwd_hash │ │ status │ │ unit_price │
│ is_admin │ │ capacity │ └────────────────┘
└────┬─────┘ │ taken │
│ 1:N │ deposit/unit │ ┌────────────────┐
│ │ shipping_fee │ │ webhook_events │
▼ └───────┬───────┘ ├────────────────┤
┌──────────┐ │ 1:N │ idempotency_key│ PK
│ orders │◄───────────────┘ │ payload (json) │
├──────────┤ └────────────────┘
│ id, code │
│ user_id │ ┌───────────────┐
│ campaign │ │ payments │
│ status │◄───────┤ order_id │ (N:1)
│ qty │ 1:N │ kind │
│ deposit │ │ amount │
│ expires │ │ idempotency_ │
└──────────┘ │ key │
└───────────────┘Bảng chi tiết
sql
-- ═══ ENUMS ═══
CREATE TYPE campaign_status AS ENUM ('DRAFT','OPEN','CLOSED','CANCELLED');
CREATE TYPE order_status AS ENUM
('RESERVED','EXPIRED','DEPOSIT_HELD','SETTLEMENT_DUE','CONFIRMED','CANCELLED');
CREATE TYPE payment_kind AS ENUM ('DEPOSIT','SETTLEMENT');
CREATE TYPE payment_status AS ENUM ('PENDING','SUCCESS','FAILED');
-- ═══ USERS ═══
CREATE TABLE users (
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
email TEXT NOT NULL UNIQUE,
password_hash TEXT NOT NULL,
full_name TEXT,
is_admin BOOLEAN NOT NULL DEFAULT false,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
-- ═══ CAMPAIGNS ═══
CREATE TABLE campaigns (
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
title TEXT NOT NULL,
slug TEXT NOT NULL UNIQUE,
description TEXT,
status campaign_status NOT NULL DEFAULT 'DRAFT',
deposit_per_unit NUMERIC(12,0) NOT NULL, -- tiền cọc / 1 sản phẩm
settlement_window_h INT NOT NULL DEFAULT 72, -- giờ để trả phần còn lại
shipping_fee NUMERIC(12,0) NOT NULL DEFAULT 0,
capacity INT NOT NULL, -- tổng slot
taken INT NOT NULL DEFAULT 0, -- slot đã đặt
closes_at TIMESTAMPTZ, -- deadline mở bán
opened_at TIMESTAMPTZ,
closed_at TIMESTAMPTZ,
locked_tier_id BIGINT, -- set khi CLOSED (Phase 0 manual)
version INT NOT NULL DEFAULT 0, -- optimistic lock
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
CHECK (taken >= 0 AND taken <= capacity),
CHECK (deposit_per_unit >= 0)
);
-- ═══ TIERS (giá theo MOQ) ═══
CREATE TABLE campaign_tiers (
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
campaign_id BIGINT NOT NULL REFERENCES campaigns(id) ON DELETE CASCADE,
qty_threshold INT NOT NULL, -- đạt N đơn → giá này
unit_price NUMERIC(12,0) NOT NULL,
UNIQUE (campaign_id, qty_threshold)
);
-- ═══ CAMPAIGN IMAGES (gallery chi tiết — object thật nằm trên R2) ═══
ALTER TABLE campaigns ADD COLUMN banner_url TEXT; -- banner/cover duy nhất
CREATE TABLE campaign_images (
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
campaign_id BIGINT NOT NULL REFERENCES campaigns(id) ON DELETE CASCADE,
url TEXT NOT NULL, -- URL public trên R2
sort_order INT NOT NULL DEFAULT 0,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX idx_campaign_images_campaign ON campaign_images(campaign_id, sort_order);
-- ═══ ORDERS ═══
CREATE TABLE orders (
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
code TEXT NOT NULL UNIQUE, -- mã "GB-XXXX"
user_id BIGINT NOT NULL REFERENCES users(id),
campaign_id BIGINT NOT NULL REFERENCES campaigns(id),
status order_status NOT NULL DEFAULT 'RESERVED',
qty INT NOT NULL,
deposit_due NUMERIC(12,0) NOT NULL, -- phải cọc
deposit_paid NUMERIC(12,0) NOT NULL DEFAULT 0, -- đã cọc
expires_at TIMESTAMPTZ NOT NULL, -- hết hạn giữ slot
final_unit_price NUMERIC(12,0), -- set khi chốt giá
amount_due NUMERIC(12,0), -- còn phải trả
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
CHECK (qty > 0),
CHECK (deposit_due >= 0)
);
CREATE INDEX idx_orders_campaign_status ON orders(campaign_id, status);
CREATE INDEX idx_orders_user ON orders(user_id);
CREATE INDEX idx_orders_expires ON orders(status, expires_at); -- cho expiry scan
-- ═══ PAYMENTS ═══
CREATE TABLE payments (
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
order_id BIGINT NOT NULL REFERENCES orders(id),
kind payment_kind NOT NULL,
amount NUMERIC(12,0) NOT NULL,
status payment_status NOT NULL DEFAULT 'PENDING',
provider TEXT NOT NULL DEFAULT 'SEPAY',
provider_ref TEXT, -- mã giao dịch từ provider
idempotency_key TEXT NOT NULL UNIQUE, -- chống double-process
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX idx_payments_order ON payments(order_id);
-- ═══ WEBHOOK IDEMPOTENCY ═══
CREATE TABLE webhook_events (
idempotency_key TEXT PRIMARY KEY,
provider TEXT NOT NULL,
payload JSONB NOT NULL,
processed_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);Invariant bất biến (DB-enforced hoặc code)
| Invariant | Enforce ở đâu |
|---|---|
taken luôn ∈ [0, capacity] | DB CHECK + atomic SQL |
deposit_paid ≤ deposit_due | Code (PaymentService) |
final_price ≥ deposit | Code (SettlementService validate trước khi lưu) |
| Mỗi webhook chỉ xử lý 1 lần | webhook_events.idempotency_key UNIQUE |
| Mỗi payment có 1 idempotency key | payments.idempotency_key UNIQUE |
5 · BUSINESS FLOWS
5.1 Đặt slot (hot path — chống oversell)
POST /orders { campaign_id, qty }
│
▼
Handler validate → OrderService.ReserveSlot(ctx, userID, campaignID, qty)
│
▼ MỞ 1 TRANSACTION
┌──────────────────────────────────────────────────────────────┐
│ UPDATE campaigns │
│ SET taken = taken + :qty │
│ WHERE id = :campaign_id │
│ AND status = 'OPEN' │
│ AND taken + :qty <= capacity │
│ RETURNING capacity - taken AS remaining; │
│ │
│ ↳ 0 rows ⟹ hết slot / không OPEN → rollback, trả SLOT_FULL │
├──────────────────────────────────────────────────────────────┤
│ INSERT INTO orders (code, user_id, campaign_id, qty, │
│ deposit_due, status, expires_at) │
│ VALUES (:code, :uid, :cid, :qty, :qty * deposit_per_unit, │
│ 'RESERVED', now() + interval '15 minutes'); │
├──────────────────────────────────────────────────────────────┤
│ COMMIT │
└──────────────────────────────────────────────────────────────┘
│
▼
Trả về order { id, code, deposit_due, expires_at }Dạng
UPDATE ... WHERE taken + qty <= capacity RETURNINGlà atomic single-statement — không cầnSELECT FOR UPDATE, không giữ lock chờ nhau. Đây là vũ khí chống oversell khi nhiều người giành slot cuối.
5.2 Payment webhook (idempotent)
SePay → POST /webhooks/sepay (header: Authorization: Apikey <key>)
│
▼
① Verify Apikey header → sai: 401
② INSERT INTO webhook_events (idempotency_key = "sepay:<tx_id>", payload)
ON CONFLICT (idempotency_key) DO NOTHING
│
├─ rowcount = 0 → trùng, đã xử lý → trả {"success": true} ✅
└─ rowcount = 1 → mới →
③ Tách mã đơn GB-XXXXXX từ content/description
└─ không có mã ⟹ log warn + ack 200 (retry vô ích, payload đã lưu để đối soát)
④ applyTransaction(ctx, event)
MỞ TRANSACTION:
SELECT * FROM orders WHERE code = :code; -- tra theo mã đơn
Kiểm tra status còn 'RESERVED'
INSERT INTO payments (kind='DEPOSIT', status='SUCCESS', ...)
UPDATE orders SET status='DEPOSIT_HELD', deposit_paid=:amt
COMMIT
mark webhook_events.processed_at
│
▼
Trả HTTP 200 + {"success": true} (contract SePay; lỗi hạ tầng → 500 để SePay tự retry)5.3 Deposit expiry (in-process cron — Phase 0)
ExpiryService.Run(ctx):
ticker := 30s
mỗi tick:
MỞ TRANSACTION:
SELECT id FROM orders
WHERE status = 'RESERVED' AND expires_at < now()
FOR UPDATE SKIP LOCKED LIMIT 100;
--
UPDATE orders SET status = 'EXPIRED' WHERE id IN (...)
UPDATE campaigns SET taken = taken - qty -- trả slot
WHERE id IN (SELECT campaign_id FROM expired_orders)
COMMITPhase 0 dùng goroutine in-process. Restart server = mất job đang chạy, nhưng vì query idempotent (
WHERE status='RESERVED') nên tick sau sẽ bắt lại. Phase 1 chuyển sang Asynq để reliable hơn.
5.4 Settlement (Phase 0 — manual trigger)
Admin POST /admin/campaigns/:id/settle
│
▼
SettlementService.Settle(ctx, campaignID):
MỞ TRANSACTION:
① SELECT SUM(qty) FROM orders
WHERE campaign_id=:cid AND status='DEPOSIT_HELD'
FOR UPDATE; -- đóng băng tổng qty
② tra bảng tiers → chọn tier phù hợp tổng qty
③ UPDATE campaigns SET status='CLOSED', locked_tier_id=:tier
④ UPDATE orders SET final_unit_price=:price,
amount_due = qty * :price + shipping - deposit_paid,
status='SETTLEMENT_DUE'
WHERE campaign_id=:cid AND status='DEPOSIT_HELD'
COMMIT
│
▼
Admin xem bảng amount_due trên dashboard → thu tiền thủ công
(Sau khi user chuyển khoản, admin xác nhận → CONFIRMED)Phase 3 tự động hoá bước này bằng SettlementBatch worker. Phase 0 admin làm tay vì shop nhỏ, vài đơn.
5.5 Order State Machine
đặt slot (15')
┌──────────────────┐
│ RESERVED │──── hết hạn (expiry cron) ───▶ EXPIRED
└────────┬─────────┘
webhook cọc (idempotent)
▼
┌──────────────────┐
│ DEPOSIT_HELD │◀── đơn này mới được đếm vào MOQ/tier
└────────┬─────────┘
campaign settle (khoá tier)
▼
┌──────────────────┐
│ SETTLEMENT_DUE │◀── amount_due đã tính
└───┬──────────┬───┘
trả đủ │ │ huỷ / quá hạn
▼ ▼
┌───────────┐ ┌────────────┐
│ CONFIRMED │ │ CANCELLED │──▶ (refund cọc — Phase 3)
└───────────┘ └────────────┘6 · QUYẾT ĐỊNH THEN CHỐT
| # | Quyết định | Vì sao |
|---|---|---|
| 1 | PostgreSQL là source of truth duy nhất | Không cache quyết định ghi. Redis (sau này) chỉ đọc/queue |
| 2 | Không ORM, dùng sqlc + pgx | Hot path cần kiểm soát SQL từng dòng. sqlc vẫn type-safe |
| 3 | Layered: Handler → Service → Repo | Tách rõ để test + sau này tách binary không đập đi |
| 4 | Repository qua interface | Service test bằng mock repo, không cần DB |
| 5 | Atomic SQL chống oversell | UPDATE ... WHERE taken+qty<=capacity — không lock chờ |
| 6 | Webhook idempotent-first | webhook_events UNIQUE key — provider retry vô hại |
| 7 | In-process cron cho expiry | Phase 0 đủ đơn giản, không cần Redis. Idempotent nên restart-safe |
| 8 | internal/ cho toàn bộ code | Không ai import được từ ngoài — sạch boundary |
| 9 | JWT access ngắn + refresh dài | Access 15', refresh 7d lưu DB (để revoke được) |
| 10 | Error domain ở model/errors.go | Handler map error → HTTP status, không leak SQL error ra ngoài |
| 11 | Observability gom về 1 package | Toàn bộ tracing + logs nằm trong internal/pkg/telemetry — call-site không import vendor trực tiếp |
| 12 | Tracing bằng OTel chuẩn W3C | Instrument 1 lần, đổi backend chỉ đổi OTLP endpoint; FE/worker tương lai nối cùng trace qua traceparent |
7 · KHI NÀO CẦN ĐỔI
Cấu trúc này cố tình đơn giản cho Phase 0. Dưới đây là những gì thay đổi ở phase sau — không phải việc của bây giờ:
| Đổi | Ở Phase | Lý do |
|---|---|---|
| Expiry cron → Asynq delayed job | Phase 1 | Reliable khi restart, có retry/backoff |
| Thêm Redis (queue + cache) | Phase 1 | Chạy Asynq, sau dùng cache |
| Thêm Notifier (Telegram) | Phase 1 | Tự động thông báo thay vì ngồi canh |
| SSE Hub (goroutine + channel) | Phase 2 | Realtime đếm slot |
| Rate Limiter (Redis sliding window) | Phase 2 | Chống spam |
| Outbox Pattern | Phase 3 | Nối state transition với side effects |
| Settlement → SettlementBatch worker | Phase 3 | Tự động sinh bill hàng loạt |
| Tách Order Engine (gRPC binary riêng) | Phase 4 | Hot path chịu tải cao |
| RBAC bool → Casbin | Phase 4 | Permission chi tiết từng action |
| Thêm workspace_id + RLS | Phase 5 | Multi-tenant |
Quy tắc: Chỉ refactor khi phase hiện tại bắt đầu nghẽn. Layered structure ở trên đã chừa sẵn chỗ để tách — không cần viết lại từ đầu.