iT邦幫忙

2026 iThome 鐵人賽

DAY 29
1
Modern Web

用 Astro 打造 Content-first 前端網站:30 天從靜態內容到會員、資料庫與選型(3rd)系列 第 29

內容站想長出購物車,讀寫該分別交給誰、又該在哪收手?

  • 分享至 

  • xImage
  •  

Day 24:Better Auth 登入與收藏做完後,購物車可以沿用同一個資料關係:都是「使用者對某個東西的一筆紀錄」,差別在於購物車還有數量、小計,以及「要不要結帳」的邊界。前 28 天分開用過的 Content
Collections、Actions、Drizzle + Turso 和 Better Auth,第一次在這篇串成一個可操作的小 app。

購物車很容易一路往外長:有了購物車,接著就想加結帳、接金流;金流後面又跟著對帳、算庫存與做訂單狀態機。原本三天能收工的 side
project,兩週後就變成沒人維護得動的半套電商。

「怎麼做出購物車」只是其中一部分;這篇主要回答兩個問題:購物車的每種互動該交給誰,以及功能做到哪裡就停。讀寫依「四條線」分流;這個最小 app 的收手線仍是「購物車資料模型」,只完成資料模型與 CRUD,不接金流。判斷時只問四件事:是否寫 DB、是否只影響 UI、是否需要自訂 HTTP,以及是否已進入完整電商。

四條線:每種互動該放哪裡

每加一個功能都要決定「這要放哪」;缺少固定判準,小 app 很快就會越界。四條線先替每種互動分類,再決定它該交給哪一層。

互動 交給誰 判準
改到 DB 的 mutation(加入 / 改量 / 移除) Astro Action 要伺服器驗證輸入、授權、讀 secret、動 DB
純 UI 狀態(數量 stepper 的即時顯示、展開收合) 純 client island 不需持久化,伺服器不用知道
對外 / 自訂 HTTP(Stripe webhook、第三方 callback) Endpoint(.ts route) 要拿 raw body、驗簽、控制 status/headers
實際扣款、對帳、庫存鎖定 收手(不做) 這是完整電商,不是「最小 app」

要先開工,記住前兩條就夠:動到資料庫的走 Action,只影響畫面的留在 client。後兩條用來判斷專案何時開始跨界;Stripe
webhook 對應對外 HTTP,扣款則越過收手線。

純讀直接查,互動寫入才走 Action

Astro Actions 很適合處理伺服器互動,但不必把每次資料存取都包成 action。商品清單就是一個不該包的例子。

商品清單本身是「純讀、而且可以預渲染」的資料;可以預渲染的頁面在 build 時查,這個 demo 因為要讀登入狀態與 DB,則在 request 時查。兩種情況都可以直接在
.astro 的 frontmatter 呼叫 createDb,由伺服器先算好,對 SEO 友善,也不必等 client 端 JS 再送一次請求:

---
import { createDb } from '../../db'; import { products } from '../../db/schema'; import { TURSO_DATABASE_URL,
TURSO_AUTH_TOKEN } from 'astro:env/server';

export const prerender = false; // 這頁要讀登入狀態與 DB,走 on-demand

const db = createDb(TURSO_DATABASE_URL, TURSO_AUTH_TOKEN); // 純讀、可預渲染 → 直接查,不包成 action const catalog =
await db.select().from(products).orderBy(products.name);
---

Action 處理的是 client 互動觸發的伺服器工作。加入購物車、改數量和移除都會立刻改 DB,還要把結果回饋給 UI,這類動作才交給 action。

寫過 Next.js server actions,可能會順手把每個 select 都變成 server
function。在 Astro 裡,能在 build 或 request 時一次算好的資料,不需要再做成由 client 觸發的端點;少一次往返,也能讓程式碼裡「哪些是讀、哪些是寫」清楚分開。

購物車存在哪?四種方案各有 trade-off

寫 action 前,先決定購物車狀態存在哪,不能只「照抄一種寫法」。常見做法有四種,差別在持久性、登入要求與實作成本:

選項 優點 缺點 適用
DB 表(外鍵接 user.id 跨裝置持久、伺服器可信任、複用 favorites 已驗證路徑 一定要登入、每次操作一趟 DB、要處理匿名 merge 會員制、購物車要長期保存
Signed cookie(只存 {id, qty} 匿名也能用、零 DB、edge 友善 約 4KB 上限、需自簽防竄改、跨裝置不同步 想支援匿名、品項數少
Server session 安全、可放較多資料 需 session 儲存後端,徒增複雜度 已有成熟 session 基建
localStorage 零後端、實作最快 SSR 讀不到、action 無法驗證、清快取就消失 純前端 demo,不作為正式購物車

這個最小 app 選 DB 表 + 強制登入;這只是依現有條件取捨,不代表「DB 一定最好」。Better
Auth、Turso 都已經存在,購物車可以沿用
Day 24:Better Auth 登入與收藏驗證過的「登入 → 寫 DB」路徑,不必另做 cookie 簽章或匿名購物車 merge。未登入時回
UNAUTHORIZED 並引導登入,跟 toggleFavorite 一樣。少引入一種狀態儲存機制,就少一組電商特有的同步邏輯。

金額欄位有一個不能省的細節:價格要用「最小單位整數」(分)儲存,不用浮點數。0.1 + 0.2 在浮點數裡不等於
0.3,這類誤差不能出現在金額計算裡。接著在 src/db/schema.ts 加入兩張表:

export const products = sqliteTable("products", {
  id: integer("id").primaryKey({ autoIncrement: true }),
  name: text("name").notNull(),
  priceCents: integer("price_cents").notNull(), // 最小單位整數,避免浮點誤差
  description: text("description"),
  createdAt: text("created_at")
    .notNull()
    .default(sql`(CURRENT_TIMESTAMP)`),
});

export const cartItems = sqliteTable(
  "cart_items",
  {
    id: integer("id").primaryKey({ autoIncrement: true }),
    // 外鍵接 Better Auth 的 user.id;刪 user 連帶清空購物車
    userId: text("user_id")
      .notNull()
      .references(() => user.id, { onDelete: "cascade" }),
    // 外鍵接自家 products.id;商品刪除連帶移出購物車
    productId: integer("product_id")
      .notNull()
      .references(() => products.id, { onDelete: "cascade" }),
    qty: integer("qty").notNull().default(1),
    createdAt: text("created_at")
      .notNull()
      .default(sql`(CURRENT_TIMESTAMP)`),
  },
  // 一位使用者 × 一個商品在購物車只有一列,支撐下一節的 upsert 累加
  (table) => [unique("cart_user_product_unq").on(table.userId, table.productId)],
);

cart_items 的複合 unique 是後面 upsert 判定衝突列的依據。schema 加完後,執行
npm run db:generate && npm run db:migrate,產生並套用 migration。

加入購物車:用 upsert 累加,伺服器重查商品

同一個商品第二次「加入購物車」時,數量要累加在原列,不能再新增一列或直接覆蓋原數量。前面的複合 unique 搭配 Drizzle 的
onConflictDoUpdate,可以在一次寫入裡完成:

addToCart: defineAction({
  input: z.object({
    productId: z.coerce.number().int().positive(),
    qty: z.coerce.number().int().min(1).max(99).default(1),
  }),
  handler: async ({ productId, qty }, ctx) => {
    const user = ctx.locals.user;
    if (!user) throw new ActionError({ code: 'UNAUTHORIZED', message: '請先登入才能加入購物車' });

    const db = createDb(TURSO_DATABASE_URL, TURSO_AUTH_TOKEN);

    // never trust client:前端傳來的 productId 一律重查存在
    const [p] = await db.select().from(products).where(eq(products.id, productId));
    if (!p) throw new ActionError({ code: 'NOT_FOUND', message: '找不到這個商品' });

    // 命中 (user, product) 唯一鍵就把數量加上去,而不是新增一列
    await db.insert(cartItems)
      .values({ userId: user.id, productId, qty })
      .onConflictDoUpdate({
        target: [cartItems.userId, cartItems.productId],
        set: { qty: sql`${cartItems.qty} + ${qty}` },
      });
    return { ok: true };
  },
}),

授權與輸入驗證要分開。授權直接讀 handler 裡的 ctx.locals.user,action 不必再查 session,因為
Day 23:middleware 與 locals 的分工已把登入狀態放進 locals。但 ctx.locals.user
存在,只能證明使用者已登入,不能證明 productId 有效;前端可以傳入任何數字,伺服器仍要重查商品是否存在。

node scripts/cart-smoke.ts 對本機 file:local.db 實跑後,結果如下:

=== 購物車(upsert 後)===
Astro 貼紙包 ×3 → 小計 36000 分
Islands 馬克杯 ×1 → 小計 32000 分
貼紙包數量應為 3:實際 3
總計 68000 分(= NT$680)

貼紙包分兩次加入(1 + 2),資料庫裡仍只有一列,數量是 3;onConflictDoUpdateqty = qty + n 已生效。

改量與移除:mutation 走 action,沒 JS 也能送

改數量和移除都會寫 DB,因此也交給 action。這兩支使用
accept: 'form',讓購物車列可以直接用純 HTML 表單送出;即使關掉 JavaScript,操作仍然有效(漸進增強):

setQty: defineAction({
  accept: 'form',
  input: z.object({
    productId: z.coerce.number().int(),
    qty: z.coerce.number().int().min(0).max(99),
  }),
  handler: async ({ productId, qty }, ctx) => {
    const user = ctx.locals.user;
    if (!user) throw new ActionError({ code: 'UNAUTHORIZED', message: '請先登入' });
    const db = createDb(TURSO_DATABASE_URL, TURSO_AUTH_TOKEN);
    const where = and(eq(cartItems.userId, user.id), eq(cartItems.productId, productId));
    if (qty === 0) { await db.delete(cartItems).where(where); return { removed: true }; }
    await db.update(cartItems).set({ qty }).where(where);
    return { qty };
  },
}),

頁面端用一個普通的 <form> POST 到這個 action,數量欄就是一個 <input type="number">

<form method="POST" action="{actions.setQty}">
  <input type="hidden" name="productId" value="{r.productId}" />
  <input type="number" name="qty" value="{r.qty}" min="0" max="99" />
  <button type="submit">更新</button>
</form>

Astro 5 之後到 7,表單 action 出錯後不會自動 redirect 回上一頁,成功與失敗都要自行處理。frontmatter 用
Astro.getActionResult
取得結果;這一頁每次 request 都會重新查購物車,因此 POST 後重新渲染就會拿到最新明細,不必手動導頁:

const qtyResult = Astro.getActionResult(actions.setQty); // 小計一律在伺服器用 DB 的 price 重算,永不信任前端傳來的價格
const totalCents = cart.reduce((s, r) => s + r.priceCents * r.qty, 0);

小計要在伺服器重算,不能直接採用前端傳來的總價,因為使用者可以修改前端送出的數字。價格以 DB 為準:數量由使用者輸入,每筆小計仍由伺服器用資料庫裡的 price 乘上數量,這才是此處
never trust client 的意思。

數量 stepper 的「+ /
−」按鈕只負責即時更新畫面,屬於純 UI,不必每按一次就呼叫 action。使用者確認數量後再送出即可;Action 只在這時接手,符合「純 UI 留在 client」的分工。

單筆 CRUD 用不到交易,跨多列才需要

這個最小 app 一次都用不到資料庫交易(transaction)。是否需要交易,可以用來判斷功能走到哪裡。

這裡的 CRUD 都是單筆寫入:加一列、改一列、刪一列。單筆寫入本身具備原子性,不必再包交易。要做到「多列一起成功或一起失敗」,例如「下單時扣庫存 + 建訂單 + 清購物車」,才需要多步原子操作;那已經屬於完整電商。

一旦操作需要跨多列的交易一致性,功能就已經離開「內容站加一點互動」的範圍,進入電商後端,也越過本篇的收手線。

部署環境也會影響這條邊界。這個 app 跑在 Cloudflare Workers,透過 @libsql/client/web 以 HTTP 連接 Turso;互動式
db.transaction() 需要在多次往返間維持連線狀態,而純 HTTP、無狀態的 web
client 對此支援是已知的敏感點。若要在 Workers 上執行原子的多步寫入,較穩妥的做法是
db.batch([...]):一次送出多條,並維持整批成敗一致;這個最小購物車連 batch 都不需要。

schema 內的外鍵連帶刪除(cascade)則能直接驗證。兩個外鍵都設定 onDelete: 'cascade',實跑結果如下:

PRAGMA foreign_keys = 1
=== 外鍵 cascade ===
刪 user 後其 cart_items 剩餘 0 列(0 = cascade 生效)

刪掉一個 user,他的購物車明細自動清空。

這份證據仍有一個缺口,範圍與 Day 24:Better Auth 登入與收藏相同:測試使用 Node 的
@libsql/client,其中 foreign_keys 預設開啟,cascade 也確實生效;app 部署時則使用 @libsql/client/web 的 HTTP
transport,真實 workerd 裡的 cascade 行為尚未驗證。完整驗證需要 turso dev + wrangler dev。這個缺口只限於 HTTP
transport 的 cascade;最小購物車仍維持單筆寫入,不再延伸到互動式交易。

用 Stripe 劃出金流的收手線

若要接金流,Stripe Checkout 的最小流程分成三步:

  1. 伺服器建立 Checkout Session:secret key 只在伺服器,讀
    astro:env/server、在請求當下建 client(跟 DB 同一條規則)。這一步可以放 action 或 endpoint。
  2. 導向 Stripe 託管頁:return Astro.redirect(session.url)。信用卡欄位、3DS 全在 Stripe 網域,自家站不碰卡號。
  3. 付款結果靠 webhook 回報:Stripe POST 到自家一個 endpoint,用 stripe.webhooks.constructEvent(rawBody, sig, secret)
    驗簽後才可信。

第三步對應「四條線」裡的「對外 HTTP」:webhook 必須走 endpoint,不能走 action。它要用原始 request body 驗證
stripe-signature,也要自行控制 status code;Astro
Action 是站內、型別安全的 RPC,會先把 body 解析成型別化輸入,webhook 驗簽需要的卻是未處理的 raw
body。「為什麼不是所有伺服器互動都塞進 Action」的答案,在於 raw body 與回應控制權不同。

這個最小 app 做到「購物車資料模型 + CRUD」就停。建立 Checkout
Session 之後的扣款、webhook 驗簽與對帳、訂單狀態機、庫存一致性都屬於完整電商,本篇不做,也不安裝 stripe 依賴。demo 頁保留一顆停用的「前往結帳」按鈕,直接標示功能邊界。

讀寫分流後,每個互動都有固定位置:商品清單是 frontmatter 裡的純讀,加入購物車是 action 裡的 mutation,數量即時變化留在 client,金流則走 endpoint 或停在收手線外。這種分工也比較好測:測
addToCart 不必連前端一起跑,修改清單查詢也不會碰到寫入邏輯。若把這些互動混在一起,後續修改的影響範圍就很難判斷。

收手線:購物車資料模型 + CRUD

每個互動都套用同一組判準:寫入走 Action、純 UI 留在 client、對外 HTTP 走 endpoint,扣款則停在範圍外。四條線先定好,最小 app 才不會在加功能時一路長成半套電商。

購物車採 DB + 強制登入,因為可以沿用現有的 Better
Auth 與 Turso。金額用整數儲存,小計由伺服器重算,避免浮點誤差與前端竄改。這裡沒有交易,因為功能還停在單筆 CRUD,尚未進入多步寫入的電商流程。

明天是
Day 30:Astro 專案選型,30 天系列會以一個更大的問題收尾:哪些專案適合 Astro,哪些不適合。「知道在哪收手」也是選型判斷的一部分。


今日驗收:完成 products / cart_items 兩張表與三個 action(addToCart / setQty /
removeFromCart),/demos/cart 可操作並呈現四條邊界;npm run db:cart-smoke
已驗證 upsert 累加、join 小計與外鍵 cascade,npm run build(Cloudflare adapter)通過。


上一篇
Astro 內容站該選 Google Sheets、Keystatic 還是 Strapi?
下一篇
30 天走完,什麼專案該選 Astro,什麼別硬上?
系列文
用 Astro 打造 Content-first 前端網站:30 天從靜態內容到會員、資料庫與選型(3rd)30
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言