mercato/backend · 服務拓撲筆記

下單請求如何穿過各個微服務

Mercato 後端是典型的「閘道 + 領域服務」結構:所有 POST /v1/orders 都先到 api-gateway,由 OrderOrchestrator 統一協調,再依序呼叫 InventoryService 鎖庫存、PaymentService 扣款、最後丟訊息給 NotificationWorker 發通知。orchestrator 是整條鏈唯一的「補償/重試」入口,其他服務只負責自己的單一動作 — 出錯時要在哪一層補資料,只有 orchestrator 知道。

請求路徑

前端 App mercato.shop api-gateway POST /v1/orders OrderOrchestrator services/order/orchestrator.go InventoryService services/inventory PaymentService services/payment JWT reserve / charge

呼叫鏈逐步拆解

1
apps/storefront/src/checkout/submitOrder.ts :18-44

結帳頁送出時呼叫 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>;
}
2
services/gateway/handlers/orders.go :31-58

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)
}
3
services/order/orchestrator.go :74-128

這裡是整條鏈的核心。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
}
4
services/inventory/reserve.go :12-46

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
}
5
db/migrations/0021_orders.sql :1-22

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);