前言

Shopify 商家說的「特價」不一定是 Checkout 才套用的 Discount Function。有些需求是商品頁一開始就顯示原價刪除線與促銷價,這時常見做法是直接修改 Product Variant 的 pricecompareAtPrice

直接改價能讓 Theme 與商品頁自然呈現特價,代價是它會改變 Shopify catalog 的持久狀態。活動結束不能只把 Campaign 標成 inactive,而必須確實把每個 Variant 還原。

如果只記得折扣百分比,不記得修改前後的精確值,就沒有可靠的 recovery path。更危險的是:活動期間商家可能手動調價,還原程式若無條件覆蓋,就會把後來的正確修改一起抹掉。

原本的做法與問題症狀

最直覺的批量特價流程是:

  1. 載入選取商品或 Collection 的 Variants。
  2. 依百分比計算新價格。
  3. 呼叫 productVariantsBulkUpdate
  4. 活動結束時再用百分比反推原價。

這個流程有幾個根本問題。

首先,百分比不能可靠反推原價。價格經過整數分、四捨五入與 compare-at price 調整後,反向計算可能得到不同結果。

其次,productVariantsBulkUpdate 一次以單一 Product 為單位。活動包含多個 Products 時,前兩組可能成功、第三組失敗;整個 HTTP request 不是跨 Products 的資料庫 transaction。

第三,活動套用後商家或另一個系統可能改價:

原價 100
活動套用 80
商家調整為 75

如果還原程式看到 Campaign 結束就直接寫回 100,會覆蓋商家後來設定的 75。

最後,如果允許刪除尚未還原的 Campaign,連原價與部分成功進度也會一起消失,之後只能人工查 log 猜測。

問題是怎麼推敲出來的?

這個功能與 Discount Function 的最大差別是狀態責任:

Discount Function 直接修改 Variant price
每次 Cart/Checkout 重新計算 商品資料被持久修改
停用 discount node 即停止 必須寫回原值
設定錯誤可 fail closed 不折扣 部分寫入可能已經改變 catalog
通常不需要保存商品原價 必須保存精確 before/after snapshot

因此 recovery 不能事後補。安全順序應該是先保存可還原資料,再執行第一個 Shopify mutation。

同時,應用程式需要知道哪些 Variant 正由其他活動控制。否則兩個 Campaign 可以先後對同一 Variant 套用不同百分比,後啟動的活動會把前一個活動價格當成「原價」,任何一方還原都可能破壞另一方。

最後,恢復流程不能只問「現在是不是特價」。它要比較目前值與 snapshot 中的兩個狀態:

  • 等於 applied value:可以安全還原。
  • 已等於 original value:代表已還原,可冪等完成。
  • 兩者都不等:視為衝突,不自動覆蓋。
  • Variant 不存在:不能假裝成功。

最後採用的架構

實作最後加入三個核心資料模型。

Campaign 狀態機

applying
   ├─ active
   └─ apply_failed

active/apply_failed/restore_failed
   └─ restoring
        ├─ restored
        └─ restore_failed

狀態轉換使用帶條件的 database update,避免同一個 Campaign 被兩個 request 同時還原。

Variant Snapshot

每個 Variant 在寫入前保存:

  • originalPrice
  • originalCompareAtPrice
  • appliedPrice
  • appliedCompareAtPrice
  • appliedAt
  • restoredAt
  • restoreConflictAt
  • ignoredAt

Snapshot 同時是 recovery 資料與操作稽核,不需要靠百分比反推。

Variant Lease

資料庫以 shop + variantId 作為唯一鍵建立 lease。建立 Campaign 與 snapshots 時一起建立 leases;若相同 Variant 已被另一個未結束活動占用,database unique constraint 會阻止第二個活動開始。

只有 Campaign 真正完成還原,或使用者明確處理衝突後,才在 transaction 中釋放 leases。

關鍵實作

套用活動時,先載入所有目標並建立 snapshots,再建立 Campaign:

load targets
  → calculate original/applied values
  → transactionally create campaign + snapshots + leases
  → update Shopify variants grouped by product

每組 Product 寫入前會重新讀取目前價格,確認仍等於 snapshot 的 original value。接著使用:

productVariantsBulkUpdate(
  productId: $productId
  variants: $variants
  allowPartialUpdates: false
)

allowPartialUpdates: false 代表同一 Product 這組 Variants 有任何錯誤時,不保存該組的有效部分。但它不會讓多個 Products 變成一個跨請求 transaction,所以每組成功後立即標記對應 snapshots 的 appliedAt

若後續組別失敗,Campaign 進入 apply_failed,已成功的部分仍有快照與進度,可以進入還原流程。

還原時先重新載入目前 Variant prices,逐筆分類:

if (current === applied) return 'restore';
if (current === original) return 'already_restored';
if (!current) return 'missing';
return 'conflict';

只有 restore 會寫回 original value;already_restored 直接標記完成;conflictmissing 不覆蓋,留下人工確認。

沒有衝突時,Campaign 標成 restored 並在同一個 database transaction 釋放 leases。有衝突時保持 restore_failed,商家可以查看 snapshot,決定如何處理;若選擇略過衝突並結案,系統會保留快照與 ignoredAt,再釋放 lease。

刪除同樣使用 database condition,只允許 status = restored 的 Campaign 被刪除,避免先查狀態、後刪除之間產生 race condition。

實際驗證

這次測試涵蓋的不是只有價格計算:

  • 金額先換成 cents 計算,再輸出 Shopify decimal money string。
  • 建立 Campaign 時同時產生 snapshots 與 Variant leases。
  • 相同 shop 與 Variant 不能被兩個活動同時租用。
  • Variant updates 依 Product 分組。
  • 同一 Product 使用 allowPartialUpdates: false
  • 每個成功 Product group 都先保存 appliedAt 再繼續。
  • 寫入前價格已改變時,中止批量操作。
  • 讀取目前價格時按安全數量分塊。
  • 還原能區分 restorealready_restoredconflictmissing
  • 衝突 Variant 不會被自動覆蓋。
  • Campaign 完成與 lease release 位於同一個 transaction。
  • 只有 restored Campaign 能被刪除。
  • 超過商品或 Variant 安全上限時,在建立 snapshots 前拒絕。

這些測試承認外部 Shopify mutation 無法和本地 PostgreSQL 共用 transaction,所以把「每組已完成進度」變成可持續保存的狀態。

仍然存在的限制

目前流程仍由一次 server action 執行,雖然有較長的 function duration 與數量上限,大型 catalog 最後仍應演進成可續跑的 background jobs,而不是持續拉長單次 request。

Snapshot 能避免自動覆蓋後續改價,不能替商家判斷哪一個新價格才是正確答案。衝突最後仍需要人工選擇:保留目前價格、手動改回,或明確略過並結案。

另外,直接修改 Variant price 會影響商品資料本身;Markets、B2B catalog、其他價格同步 App 與 ERP 可能有自己的定價來源。正式導入前要先定義誰擁有 catalog price,而不是只從 API 權限判斷能不能寫。

allowPartialUpdates: false 只保護同一個 Product mutation,無法提供整個 Campaign 的跨 Product 原子性。這正是 snapshots、per-group progress 與 retry-safe restore 仍然必要的原因。

可以延伸到哪些情境?

只要功能會大量修改外部系統,而且之後需要回復,就可以套用相同模式:

  1. 寫入前保存 before/after snapshot。
  2. 使用 lease 或唯一約束避免重疊操作。
  3. 把跨 API 的批量工作拆成可記錄進度的 group。
  4. 還原前比較「現在值」是否仍等於「當時套用值」。
  5. 將衝突交給明確的人工作業,不無條件覆蓋。
  6. 只有完成 recovery 後才允許刪除操作紀錄。

下一批文章會繼續往 Automatic App Discount 的生命週期、Storefront/Checkout/POS 一致性與 Billing 存取控制前進。

參考資料