sell整合實戰
shopping_cart

架構藍圖:線上商店

把 Pages、Workers、D1、Durable Objects、R2、KV、Turnstile 串成一條真正撐得住的結帳流程。

7Cloudflare 產品
4核心資料表
3結帳步驟
1單一平台
insights

我們要蓋什麼?

我們要完全用 Cloudflare 蓋一間小型線上商店。客人逛商品、把東西丟進購物車、用信用卡結帳——而所有環節都跑在 Cloudflare 的邊緣網路上,只有一個外部服務:金流商。

把這頁當成一張接線圖。每個 Cloudflare 產品各司其職:Pages 負責前台、Worker 是 API 大腦、D1 是 SQL 資料庫、Durable Objects 讓每台購物車和庫存數字維持一致、R2 放商品圖片、KV 快取商品目錄,而 Turnstile 加上 WAF 守住結帳關卡。

storefront

把它想成…

一間實體店面:Pages 是櫥窗、Worker 是收銀員、D1 是後台帳本、Durable Object 是你手上那只獨一無二的提籃(別人碰不到)、R2 是放照片的倉庫,而金流商就是銀行的刷卡機。

schema系統架構

商品圖片

結帳驗證

目錄快取

商品與訂單

購物車與庫存

刷卡扣款

購物者瀏覽器

WAF 與 DDoS 防護

Pages 商店前台

Worker API

R2 圖片儲存桶

Turnstile

Workers KV

D1 資料庫

購物車 Durable Objects

外部金流

account_tree

每個產品的角色

整間商店由七個 Cloudflare 產品,加上一個外部金流服務組成。以下是每一個各自負責什麼。

web

Pages — 前台

把靜態或 React 商店介面放在全球 CDN 上。瀏覽器最先接觸的就是它。

bolt

Workers — API

大腦:負責路由 /api 請求、跑商業邏輯,並與每個儲存層溝通。

database

D1 — SQL 資料庫

用關聯式資料表存放商品、客戶、訂單與訂單明細。

lock_clock

Durable Objects — 購物車與庫存

每台購物車一個實例;把庫存變動排成一列,避免兩個買家把最後一件商品同時賣掉。

image

R2 — 商品圖片

存照片的物件儲存,下載免流量費。資料表的 image_key 欄位就指向這裡。

bolt_outline

KV — 目錄快取

在邊緣快取商品清單,讓每次瀏覽不必都打到 D1。

verified_user

Turnstile — 真人驗證

結帳表單上的友善驗證碼;Worker 會在扣款前驗證它的 token。

shield

WAF — 防火牆

在惡意流量與機器人觸及 Pages 或 Worker 之前就先擋下。

credit_card

金流商 — 外部

唯一不在 Cloudflare 上的服務。Worker 以伺服器對伺服器方式呼叫它的 API 來刷卡。

hub

同一平台,多個綁定

因為每個產品都住在 Cloudflare 上,Worker 透過綁定(env.DB、env.CART、env.IMAGES、env.CATALOG)就能存取它們,不必用網址加金鑰去打網路請求。設定更少、密鑰更少、延遲更低。

schema

資料模型

四張資料表就能撐起一間基本商店。一位客戶可以下很多筆訂單;每筆訂單包含許多訂單明細(order_items);每筆明細指向一個商品。order_items 這張中介表記錄了每樣商品買了幾個、單價多少。

schema實體關聯圖

下單

包含

列於

CUSTOMERS

int

id

PK

text

email

text

name

ORDERS

int

id

PK

int

customer_id

FK

text

status

int

total_cents

ORDER_ITEMS

int

id

PK

int

order_id

FK

int

product_id

FK

int

qty

PRODUCTS

int

id

PK

text

title

int

price_cents

int

stock

建立資料表

sqlschema.sql
-- schema.sql : the four core tables of the store
CREATE TABLE customers (
  id    INTEGER PRIMARY KEY,
  email TEXT UNIQUE NOT NULL,
  name  TEXT
);

CREATE TABLE products (
  id          INTEGER PRIMARY KEY,
  title       TEXT NOT NULL,
  price_cents INTEGER NOT NULL,
  stock       INTEGER NOT NULL DEFAULT 0,
  image_key   TEXT                      -- object key inside the R2 bucket
);

CREATE TABLE orders (
  id          INTEGER PRIMARY KEY,
  customer_id INTEGER REFERENCES customers(id),
  email       TEXT,
  status      TEXT NOT NULL DEFAULT 'pending',  -- pending | paid | failed
  total_cents INTEGER NOT NULL,
  created_at  TEXT DEFAULT CURRENT_TIMESTAMP
);

CREATE TABLE order_items (
  id               INTEGER PRIMARY KEY,
  order_id         INTEGER NOT NULL REFERENCES orders(id),
  product_id       INTEGER NOT NULL REFERENCES products(id),
  qty              INTEGER NOT NULL,
  unit_price_cents INTEGER NOT NULL
);

CREATE INDEX idx_items_order ON order_items(order_id);
payments

金額用整數存

注意 price_cents 與 total_cents——金額一律用「整數分」存,絕不要用浮點數的「元」。浮點數會亂進位、掉零頭;整數不會。

swap_vert

結帳流程

瀏覽很簡單(從 KV/D1 讀取、用 R2 顯示圖片)。真正有趣的是結帳:五件事必須照正確順序發生——而且一旦刷卡失敗,要能乾淨地回滾。

schema結帳時序
金流D1"購物車 DO"Turnstile"Worker API"瀏覽器金流D1"購物車 DO"Turnstile"Worker API"瀏覽器POST 結帳並附 token驗證 token確認是真人預扣庫存扣減庫存更新成功已預扣與總金額寫入待付款訂單訂單編號建立扣款已付款更新訂單為已付款訂單完成
  • 先驗證 Turnstile token,讓機器人無法猛攻結帳。
  • 在購物車 Durable Object 內預扣庫存(原子操作、不超賣)。
  • 把一筆「待付款」訂單寫進 D1,取得訂單編號。
  • 呼叫外部金流商來刷卡扣款。
  • 成功就把訂單標為「已付款」;失敗就標為「失敗」並把庫存還回去。
warning

先預扣,再扣款

務必在呼叫金流之前先預扣庫存。如果先扣款才發現商品賣完了,你就收了一筆無法出貨的錢——還得辦退款。

construction

動手做:綁定與程式碼

以下是真正的接線:把 Worker 連到每個產品的 wrangler 綁定、密鑰、前台前端、結帳 Worker,以及購物車 Durable Object。

  1. 宣告所有綁定

    在 wrangler.jsonc 裡綁定 D1、KV、R2 和購物車 Durable Object,讓 Worker 能用 env.DB、env.CATALOG、env.IMAGES、env.CART 存取它們。

    jsonc
    {
      "name": "store-api",
      "main": "src/index.js",
      "compatibility_date": "2025-01-01",
    
      "d1_databases": [
        { "binding": "DB", "database_name": "store-db", "database_id": "<your-d1-id>" }
      ],
      "kv_namespaces": [
        { "binding": "CATALOG", "id": "<your-kv-id>" }
      ],
      "r2_buckets": [
        { "binding": "IMAGES", "bucket_name": "store-images" }
      ],
      "durable_objects": {
        "bindings": [
          { "name": "CART", "class_name": "Cart" }
        ]
      },
      "migrations": [
        { "tag": "v1", "new_sqlite_classes": ["Cart"] }
      ]
    }
  2. 設定密鑰

    Turnstile 與金流金鑰絕不寫進程式碼——用加密密鑰推上去。

    bash
    # Turnstile secret key (verifies the checkout challenge)
    npx wrangler secret put TURNSTILE_SECRET
    
    # Your payment provider API key (Stripe, etc.)
    npx wrangler secret put PAYMENT_KEY
  3. 套用資料表結構

    用 schema.sql 在遠端 D1 資料庫建立那四張資料表。

    bash
    npx wrangler d1 execute store-db --remote --file=./schema.sql
  4. 上傳圖片到 R2

    把每張照片放進儲存桶,再把它的 key 存到商品那一列。

    bash
    # Upload a product image to the R2 bucket, then store its key in D1
    npx wrangler r2 object put store-images/tshirt-blue.jpg --file ./tshirt-blue.jpg
    
    npx wrangler d1 execute store-db --remote \
      --command "UPDATE products SET image_key = 'tshirt-blue.jpg' WHERE id = 1;"
  5. 部署

    發布 Worker;Pages 上的前台則由它自己的 git push 來部署。

    bash
    npx wrangler deploy

前端(Pages 前台)

jspublic/store.js
// public/store.js -- runs in the browser on the Pages storefront
const API = "https://store-api.example.workers.dev";

// A stable id for this browser's cart (used to find its Durable Object)
const cartId = localStorage.getItem("cartId") || crypto.randomUUID();
localStorage.setItem("cartId", cartId);

// 1) Browse products (images load straight from R2)
async function loadProducts() {
  const res = await fetch(`${API}/api/products`);
  const products = await res.json();
  for (const p of products) {
    // <img src="https://images.example.com/<image_key>"> served by R2
    renderCard(p);
  }
}

// 2) Add to cart
async function addToCart(productId) {
  await fetch(`${API}/api/cart/add`, {
    method: "POST",
    headers: { "content-type": "application/json" },
    body: JSON.stringify({ cartId, productId, qty: 1 }),
  });
}

// 3) Checkout -- token comes from the Turnstile widget on the page
async function checkout(email) {
  const token = window.turnstile.getResponse();   // proves you are human
  const res = await fetch(`${API}/api/checkout`, {
    method: "POST",
    headers: { "content-type": "application/json" },
    body: JSON.stringify({ cartId, token, email }),
  });
  const { orderId, status } = await res.json();
  alert(`Order ${orderId}: ${status}`);
}

結帳 Worker(API)

jssrc/index.js
// src/index.js -- the store Worker API (router)
export default {
  async fetch(request, env) {
    const url = new URL(request.url);

    // Browse catalog: serve from the KV cache, fall back to D1
    if (url.pathname === "/api/products") {
      let catalog = await env.CATALOG.get("catalog", "json");
      if (!catalog) {
        const { results } = await env.DB
          .prepare("SELECT id, title, price_cents, stock, image_key FROM products")
          .all();
        catalog = results;
        await env.CATALOG.put("catalog", JSON.stringify(results), { expirationTtl: 60 });
      }
      return Response.json(catalog);
    }

    // Add to cart: route to THIS shopper's Cart Durable Object
    if (url.pathname === "/api/cart/add" && request.method === "POST") {
      const { cartId, productId, qty } = await request.json();
      const stub = env.CART.get(env.CART.idFromName(cartId));
      return stub.fetch("https://do/add", {
        method: "POST",
        body: JSON.stringify({ productId, qty }),
      });
    }

    // Checkout
    if (url.pathname === "/api/checkout" && request.method === "POST") {
      const { cartId, token, email } = await request.json();

      // 1) Turnstile: prove the buyer is a human, not a bot
      if (!(await verifyTurnstile(token, env.TURNSTILE_SECRET)))
        return new Response("Failed challenge", { status: 403 });

      // 2) Reserve stock atomically inside the Cart Durable Object
      const cart = env.CART.get(env.CART.idFromName(cartId));
      const reserved = await (await cart.fetch("https://do/reserve", { method: "POST" })).json();
      if (!reserved.ok) return new Response("Out of stock", { status: 409 });

      // 3) Create a pending order in D1
      const order = await env.DB
        .prepare("INSERT INTO orders (email, status, total_cents) VALUES (?, 'pending', ?) RETURNING id")
        .bind(email, reserved.total_cents)
        .first();

      // 4) Charge the external payment provider
      const charge = await chargePayment(env.PAYMENT_KEY, order.id, reserved.total_cents);

      // 5) Confirm, or roll the stock back on failure
      const status = charge.paid ? "paid" : "failed";
      await env.DB.prepare("UPDATE orders SET status = ? WHERE id = ?")
        .bind(status, order.id).run();
      if (!charge.paid) await cart.fetch("https://do/release", { method: "POST" });

      return Response.json({ orderId: order.id, status });
    }

    return new Response("Not found", { status: 404 });
  },
};

async function verifyTurnstile(token, secret) {
  const r = await fetch("https://challenges.cloudflare.com/turnstile/v0/siteverify", {
    method: "POST",
    headers: { "content-type": "application/json" },
    body: JSON.stringify({ secret, response: token }),
  });
  return (await r.json()).success === true;
}

async function chargePayment(apiKey, orderId, amountCents) {
  // Swap this for your provider's SDK / REST call
  const r = await fetch("https://api.payments.example/v1/charges", {
    method: "POST",
    headers: { authorization: "Bearer " + apiKey, "content-type": "application/json" },
    body: JSON.stringify({ amount: amountCents, currency: "twd", reference: orderId }),
  });
  return r.json();
}

購物車/庫存 Durable Object

jssrc/cart.js
// src/cart.js -- one Durable Object instance per shopper cart.
// A single DO is single-threaded, so stock checks never race each other.
export class Cart {
  constructor(state, env) {
    this.state = state;   // durable, consistent storage for THIS cart
    this.env = env;
  }

  async fetch(request) {
    const url = new URL(request.url);
    let items = (await this.state.storage.get("items")) || [];

    if (url.pathname === "/add") {
      const { productId, qty } = await request.json();
      items = [...items, { productId, qty }];           // immutable update
      await this.state.storage.put("items", items);
      return Response.json({ items });
    }

    if (url.pathname === "/reserve") {
      // Decrement stock for every line, all-or-nothing.
      // 'WHERE stock >= ?' makes each decrement safe under load.
      let total = 0;
      for (const it of items) {
        const row = await this.env.DB
          .prepare("UPDATE products SET stock = stock - ? WHERE id = ? AND stock >= ? RETURNING price_cents")
          .bind(it.qty, it.productId, it.qty)
          .first();
        if (!row) return Response.json({ ok: false });  // sold out -> abort
        total += row.price_cents * it.qty;
      }
      return Response.json({ ok: true, total_cents: total });
    }

    if (url.pathname === "/release") {
      // Payment failed: give the reserved stock back.
      for (const it of items) {
        await this.env.DB
          .prepare("UPDATE products SET stock = stock + ? WHERE id = ?")
          .bind(it.qty, it.productId).run();
      }
      await this.state.storage.delete("items");
      return Response.json({ ok: true });
    }

    return new Response("DO route not found", { status: 404 });
  }
}
school

為什麼能成立:那些難的部分

shopping_basket

購物車一致性

每台購物車透過 idFromName(cartId) 對應到剛好一個 Durable Object。該購物車的所有加入/預扣都在同一個實例上、依序執行,所以讀寫不會交錯,也不會掉更新。

lock

用 DO 管庫存

Durable Object 是單執行緒。把庫存扣減都導進它,兩個搶最後一件的客人就會一前一後被處理——沒有競態、不會超賣。

shield_lock

保護結帳

WAF 在邊緣擋掉惡意流量;Turnstile 證明是真人在結帳;Worker 在呼叫金流之前,會在伺服器端先驗證那個 token。

cached

先快取,再回源

瀏覽時先從 KV 讀目錄,只有快取沒命中才查 D1,查完再回填 KV。頁面更快,D1 讀取也少很多。

link

用綁定,不用網址

env.DB/env.CART/env.IMAGES 是部署時注入的綁定。自家服務之間不需要連線字串、也不需要 API 金鑰。

undo

失敗就回滾

如果刷卡被拒,訂單標為「失敗」,DO 會把預扣的庫存還回去,讓架上數量始終誠實。

tips_and_updates

陷阱與小提示

verified

金流一律在伺服器端驗證

永遠不要相信瀏覽器說「這筆已付款」。要從 Worker 呼叫金流商的 API(或用有簽章的 webhook)來確認付款,再依結果更新訂單狀態。

  • 把 KV 目錄的 TTL 設短(例如 60 秒),或在商品變動時就清掉那個 key,價格才不會過期。
  • 讓金流呼叫具備冪等性——把訂單編號當作 reference 傳過去,重送的請求就不會重複扣款。
  • D1 只存圖片 key;公開的 R2 網址在前端組出來。這讓資料列小、網址也好替換。
  • 用金流商的 webhook 當後援,以防客人在 Worker 看到「已付款」之前就關掉分頁。
  • 加一個定時工作(Cron Trigger),把那些開始結帳卻沒完成的購物車所預扣的庫存釋放掉。
rocket_launch

先小做,再長大

第一天不必七個產品全上。先從 Pages+Worker+D1 開始,有圖片再加 R2、瀏覽量變大再加 KV、出現庫存競態再加 Durable Objects,而在真正收錢之前先把 Turnstile/WAF 補上。