架構藍圖:線上商店
把 Pages、Workers、D1、Durable Objects、R2、KV、Turnstile 串成一條真正撐得住的結帳流程。
我們要蓋什麼?
我們要完全用 Cloudflare 蓋一間小型線上商店。客人逛商品、把東西丟進購物車、用信用卡結帳——而所有環節都跑在 Cloudflare 的邊緣網路上,只有一個外部服務:金流商。
把這頁當成一張接線圖。每個 Cloudflare 產品各司其職:Pages 負責前台、Worker 是 API 大腦、D1 是 SQL 資料庫、Durable Objects 讓每台購物車和庫存數字維持一致、R2 放商品圖片、KV 快取商品目錄,而 Turnstile 加上 WAF 守住結帳關卡。
把它想成…
一間實體店面:Pages 是櫥窗、Worker 是收銀員、D1 是後台帳本、Durable Object 是你手上那只獨一無二的提籃(別人碰不到)、R2 是放照片的倉庫,而金流商就是銀行的刷卡機。
每個產品的角色
整間商店由七個 Cloudflare 產品,加上一個外部金流服務組成。以下是每一個各自負責什麼。
Pages — 前台
把靜態或 React 商店介面放在全球 CDN 上。瀏覽器最先接觸的就是它。
Workers — API
大腦:負責路由 /api 請求、跑商業邏輯,並與每個儲存層溝通。
D1 — SQL 資料庫
用關聯式資料表存放商品、客戶、訂單與訂單明細。
Durable Objects — 購物車與庫存
每台購物車一個實例;把庫存變動排成一列,避免兩個買家把最後一件商品同時賣掉。
R2 — 商品圖片
存照片的物件儲存,下載免流量費。資料表的 image_key 欄位就指向這裡。
KV — 目錄快取
在邊緣快取商品清單,讓每次瀏覽不必都打到 D1。
Turnstile — 真人驗證
結帳表單上的友善驗證碼;Worker 會在扣款前驗證它的 token。
WAF — 防火牆
在惡意流量與機器人觸及 Pages 或 Worker 之前就先擋下。
金流商 — 外部
唯一不在 Cloudflare 上的服務。Worker 以伺服器對伺服器方式呼叫它的 API 來刷卡。
同一平台,多個綁定
因為每個產品都住在 Cloudflare 上,Worker 透過綁定(env.DB、env.CART、env.IMAGES、env.CATALOG)就能存取它們,不必用網址加金鑰去打網路請求。設定更少、密鑰更少、延遲更低。
資料模型
四張資料表就能撐起一間基本商店。一位客戶可以下很多筆訂單;每筆訂單包含許多訂單明細(order_items);每筆明細指向一個商品。order_items 這張中介表記錄了每樣商品買了幾個、單價多少。
建立資料表
-- 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);金額用整數存
注意 price_cents 與 total_cents——金額一律用「整數分」存,絕不要用浮點數的「元」。浮點數會亂進位、掉零頭;整數不會。
結帳流程
瀏覽很簡單(從 KV/D1 讀取、用 R2 顯示圖片)。真正有趣的是結帳:五件事必須照正確順序發生——而且一旦刷卡失敗,要能乾淨地回滾。
- 先驗證 Turnstile token,讓機器人無法猛攻結帳。
- 在購物車 Durable Object 內預扣庫存(原子操作、不超賣)。
- 把一筆「待付款」訂單寫進 D1,取得訂單編號。
- 呼叫外部金流商來刷卡扣款。
- 成功就把訂單標為「已付款」;失敗就標為「失敗」並把庫存還回去。
先預扣,再扣款
務必在呼叫金流之前先預扣庫存。如果先扣款才發現商品賣完了,你就收了一筆無法出貨的錢——還得辦退款。
動手做:綁定與程式碼
以下是真正的接線:把 Worker 連到每個產品的 wrangler 綁定、密鑰、前台前端、結帳 Worker,以及購物車 Durable Object。
宣告所有綁定
在 wrangler.jsonc 裡綁定 D1、KV、R2 和購物車 Durable Object,讓 Worker 能用 env.DB、env.CATALOG、env.IMAGES、env.CART 存取它們。
{ "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"] } ] }設定密鑰
Turnstile 與金流金鑰絕不寫進程式碼——用加密密鑰推上去。
# 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套用資料表結構
用 schema.sql 在遠端 D1 資料庫建立那四張資料表。
npx wrangler d1 execute store-db --remote --file=./schema.sql上傳圖片到 R2
把每張照片放進儲存桶,再把它的 key 存到商品那一列。
# 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;"部署
發布 Worker;Pages 上的前台則由它自己的 git push 來部署。
npx wrangler deploy
前端(Pages 前台)
// 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)
// 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
// 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 });
}
}為什麼能成立:那些難的部分
購物車一致性
每台購物車透過 idFromName(cartId) 對應到剛好一個 Durable Object。該購物車的所有加入/預扣都在同一個實例上、依序執行,所以讀寫不會交錯,也不會掉更新。
用 DO 管庫存
Durable Object 是單執行緒。把庫存扣減都導進它,兩個搶最後一件的客人就會一前一後被處理——沒有競態、不會超賣。
保護結帳
WAF 在邊緣擋掉惡意流量;Turnstile 證明是真人在結帳;Worker 在呼叫金流之前,會在伺服器端先驗證那個 token。
先快取,再回源
瀏覽時先從 KV 讀目錄,只有快取沒命中才查 D1,查完再回填 KV。頁面更快,D1 讀取也少很多。
用綁定,不用網址
env.DB/env.CART/env.IMAGES 是部署時注入的綁定。自家服務之間不需要連線字串、也不需要 API 金鑰。
失敗就回滾
如果刷卡被拒,訂單標為「失敗」,DO 會把預扣的庫存還回去,讓架上數量始終誠實。
陷阱與小提示
金流一律在伺服器端驗證
永遠不要相信瀏覽器說「這筆已付款」。要從 Worker 呼叫金流商的 API(或用有簽章的 webhook)來確認付款,再依結果更新訂單狀態。
- 把 KV 目錄的 TTL 設短(例如 60 秒),或在商品變動時就清掉那個 key,價格才不會過期。
- 讓金流呼叫具備冪等性——把訂單編號當作 reference 傳過去,重送的請求就不會重複扣款。
- D1 只存圖片 key;公開的 R2 網址在前端組出來。這讓資料列小、網址也好替換。
- 用金流商的 webhook 當後援,以防客人在 Worker 看到「已付款」之前就關掉分頁。
- 加一個定時工作(Cron Trigger),把那些開始結帳卻沒完成的購物車所預扣的庫存釋放掉。
先小做,再長大
第一天不必七個產品全上。先從 Pages+Worker+D1 開始,有圖片再加 R2、瀏覽量變大再加 KV、出現庫存競態再加 Durable Objects,而在真正收錢之前先把 Turnstile/WAF 補上。