前言
Shopify 商家說的「特價」不一定是 Checkout 才套用的 Discount Function。有些需求是商品頁一開始就顯示原價刪除線與促銷價,這時常見做法是直接修改 Product Variant 的 price 與 compareAtPrice。
直接改價能讓 Theme 與商品頁自然呈現特價,代價是它會改變 Shopify catalog 的持久狀態。活動結束不能只把 Campaign 標成 inactive,而必須確實把每個 Variant 還原。
如果只記得折扣百分比,不記得修改前後的精確值,就沒有可靠的 recovery path。更危險的是:活動期間商家可能手動調價,還原程式若無條件覆蓋,就會把後來的正確修改一起抹掉。
原本的做法與問題症狀
最直覺的批量特價流程是:
- 載入選取商品或 Collection 的 Variants。
- 依百分比計算新價格。
- 呼叫
productVariantsBulkUpdate。 - 活動結束時再用百分比反推原價。
這個流程有幾個根本問題。
首先,百分比不能可靠反推原價。價格經過整數分、四捨五入與 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 在寫入前保存:
originalPriceoriginalCompareAtPriceappliedPriceappliedCompareAtPriceappliedAtrestoredAtrestoreConflictAtignoredAt
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 直接標記完成;conflict 與 missing 不覆蓋,留下人工確認。
沒有衝突時,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再繼續。 - 寫入前價格已改變時,中止批量操作。
- 讀取目前價格時按安全數量分塊。
- 還原能區分
restore、already_restored、conflict與missing。 - 衝突 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 仍然必要的原因。
可以延伸到哪些情境?
只要功能會大量修改外部系統,而且之後需要回復,就可以套用相同模式:
- 寫入前保存 before/after snapshot。
- 使用 lease 或唯一約束避免重疊操作。
- 把跨 API 的批量工作拆成可記錄進度的 group。
- 還原前比較「現在值」是否仍等於「當時套用值」。
- 將衝突交給明確的人工作業,不無條件覆蓋。
- 只有完成 recovery 後才允許刪除操作紀錄。
下一批文章會繼續往 Automatic App Discount 的生命週期、Storefront/Checkout/POS 一致性與 Billing 存取控制前進。
