請求路徑
呼叫鏈逐步拆解
結帳頁送出時呼叫 POST /v1/orders,body 帶 cart_id + payment_method_id + idempotency_key。idempotency key 是前端產的 UUIDv7,用來擋使用者狂點送出按鈕 — 後端會用這個 key 做交易去重,所以前端不能每次重 retry 都重生。
展開原始碼
// apps/storefront/src/checkout/submitOrder.ts export async function submitOrder(cart: Cart, method: PayMethod) { const key = useRef(crypto.randomUUID()).current; const res = await fetch('/v1/orders', { method: 'POST', headers: { 'Idempotency-Key': key }, body: JSON.stringify({ cart_id: cart.id, payment_method_id: method.id }), }); if (!res.ok) throw new CheckoutError(await res.json()); return res.json() as Promise<OrderReceipt>; }
Gateway 的工作只有三件:驗 JWT、檢查 idempotency key 是否已處理過、把請求轉給 orchestrator。它不知道庫存或金流的存在 — 這層做完就交棒,所以 gateway 永遠不會自己回 order_id,只是 proxy orchestrator 的回應。
展開原始碼
// services/gateway/handlers/orders.go func CreateOrder(w http.ResponseWriter, r *http.Request) { claims := authz.FromContext(r.Context()) key := r.Header.Get("Idempotency-Key") if cached, ok := idempo.Lookup(key); ok { writeJSON(w, cached) return } resp, err := orchestrator.Place(r.Context(), claims.UserID, body, key) if err != nil { writeErr(w, err); return } writeJSON(w, resp) }
這裡是整條鏈的核心。Place() 依序執行三步:reserve 庫存 → charge 金流 → emit 通知事件。任何一步失敗都會跑對應的補償(庫存 release / 金流 refund),所以新增第四步前要先把補償流程一起加 — 不然 partial failure 會留下髒資料。
展開原始碼
// services/order/orchestrator.go func (o *Orchestrator) Place(ctx context.Context, uid string, in OrderInput, key string) (*Order, error) { resv, err := o.inv.Reserve(ctx, in.Lines) if err != nil { return nil, errInv(err) } pay, err := o.pay.Charge(ctx, uid, in.PaymentID, resv.Total, key) if err != nil { o.inv.Release(ctx, resv.ID) // 補償:釋放庫存 return nil, errPay(err) } order := o.repo.Persist(ctx, uid, resv, pay) o.bus.Emit(ctx, "order.created", order) return order, nil }
Reserve() 用單一 SQL 一次扣多個 SKU 的 available 欄位,並在 reservations 表寫一筆 TTL 15 分鐘的 row。如果任何 SKU 庫存不足,整個 UPDATE 不會 partial 成功(靠 RETURNING + 應用層 check),所以 orchestrator 不用自己回 rollback 已扣的 line。
展開原始碼
// services/inventory/reserve.go func (s *Store) Reserve(ctx context.Context, lines []Line) (*Reservation, error) { rows, err := s.db.Query(ctx, reserveSQL, lines) if err != nil { return nil, err } if len(rows) != len(lines) { return nil, ErrOutOfStock } rid := uuid.New() s.db.Exec(ctx, insertReservation, rid, lines, time.Now().Add(15*time.Minute)) return &Reservation{ID: rid, Lines: rows}, nil }
orders 表用 ULID 當 primary key(時間排序友善),並對 idempotency_key 加 unique constraint — 這條 constraint 就是後端「同一個 key 不能下兩次單」的最終把關。即使 gateway 的 idempotency cache miss,DB 這層也會擋掉重複請求。
展開原始碼
-- db/migrations/0021_orders.sql create table orders ( id text primary key, -- ULID user_id uuid not null references users(id), total_cents int not null, status text not null default 'created', idempotency_key text not null unique, created_at timestamptz default now() ); create index orders_user_id_idx on orders(user_id, created_at desc);