NextShop:一個在意「同時發生」的全端電商 Demo

2026年3月26日

NextShop 商品列表

為什麼做這個專案

購物網站的畫面不難做,難的是畫面背後那些「不能出錯」的地方:兩個人同時買最後一件商品、使用者連按兩次結帳、金流通知比訂單還早到……

NextShop 是一個前後端分離的電商 Demo,重點不在功能多,而是結帳流程的一致性:庫存不超賣、訂單不重複、付款通知不重複處理。


技術架構

項目使用技術
前端Next.js 15 + React 19 + TypeScript,部署在 Vercel
狀態管理Zustand(購物車)
後端Spring Boot 3 + Spring Data JPA + Spring Security,Docker 部署在 Render
資料庫PostgreSQL(Neon)
驗證JWT + 會輪替的 Refresh Token
金流Stripe PaymentIntent(Demo 模式)+ Webhook
API 文件springdoc-openapi(Swagger)

主要功能

  • 商品與分類:分類支援階層、排序與分頁,商品可有多張圖片
  • 購物車:用 UUID 識別,數量會自動合併
  • 結帳與訂單:訂單有待付款、已付款、失敗、取消、退款五種狀態,每次狀態變化都會記錄成事件
  • 金流:Stripe 付款與 Webhook,可儲存付款方式
  • 會員:登入、角色權限、收件地址管理
  • 後台:商品、分類、使用者、訂單管理,管理員的每個操作都會留下稽核紀錄
  • 排程:每分鐘處理逾時未付款的訂單,每小時檢查一次庫存與訂單是否一致

三個讓結帳「不會出錯」的設計

1. 扣庫存只用一條 SQL:不超賣

常見的錯誤寫法是「先查庫存,夠的話再扣」。兩個請求同時進來時,可能都查到還有 1 件,結果兩邊都扣成功。

這裡把檢查和扣除合併成一條 UPDATE,只有庫存足夠時才會更新:

@Query("""
    UPDATE Inventory i SET i.stock = i.stock - :qty
    WHERE i.product.id = :productId
    AND i.stock >= :qty
""")
int deductStockAtomic(@Param("productId") Long productId, @Param("qty") int qty);

資料庫保證這條語句是原子操作。回傳更新筆數為 0,就代表庫存不足,直接拋出例外並讓整筆交易回滾。

2. 冪等結帳:按兩次也只有一張訂單

前端每次結帳都會帶一個 requestId。後端先用它查有沒有已經建立的訂單,有的話直接回傳原本那一張:

return orderRepository.findByRequestIdWithItems(request.getRequestId())
        .map(OrderResponse::from)
        .orElseGet(() -> processCheckout(request));

網路重試、使用者連點,都不會產生重複訂單。取消訂單時退回庫存也用一個 stockRestored 旗標保護,同一張訂單不會被退兩次。

3. Webhook 去重:同一個付款通知只處理一次

Stripe 的 Webhook 可能重送,也可能比訂單資料更早抵達。處理順序是:

  1. 驗證簽章,確認通知真的來自 Stripe
  2. 用事件 ID 檢查是否處理過,處理過就直接回 OK
  3. 如果找不到對應的訂單,先存進「待處理」佇列並發出警示,而不是直接丟掉
event = Webhook.constructEvent(payload, sigHeader, endpointSecret);
 
if (webhookEventRepository.existsByEventId(event.getId())) {
    return "OK";
}

前端的購物車只存 cartUuid,不用流水號 ID,避免被人用遞增的數字猜到別人的購物車。


開發過程中踩到的坑

  • 連線池的 prepared statement 錯誤:資料庫走 PgBouncer 這類連線池時,JDBC 預設的 prepared statement 快取會出錯。最後在 JDBC 設定裡關掉快取(prepareThreshold=0)才解決。
  • 壓力測試:用 k6 腳本打結帳 API,20 個併發請求很穩定;拉到 50 時,免費方案資料庫的連線數先用完了。所以後端加了一個 Semaphore 限流,超過就回傳 429,保護資料庫不被打爆。
  • 免費方案的休眠:後端放在 Render 免費方案,閒置一段時間就會休眠。後來資料庫也遇到免費方案自動暫停,於是搬到閒置時只休眠、有請求就自動喚醒的 Neon。

小結

這個專案讓我體會到:電商的難度不在頁面,而在同時發生的事情。原子操作、冪等、去重,這些設計在畫面上完全看不到,卻決定了一個系統能不能被信任。

🌐 線上 Demo | 💻 GitHub

後端在免費方案上,閒置後第一次開啟需要約 50 秒喚醒。