前言
開發 Shopify 折扣 App 的第一版時,商家可以建立多個 Promotion,每個活動都有自己的商品、折扣率與排程。當時為了快速完成結帳整合,所有 Promotion 被整理成同一份 Function 設定,再交給一個 Automatic App Discount 執行。
只要活動不多、條件相近,這個架構確實能運作。但功能加入客群限制、折扣疊加與獨立啟停後,「後台有多個 Promotion,Shopify 卻只有一個 discount node」開始變成生命週期問題。
這次最後改成一個 Promotion 對應一個 Automatic App Discount。這不只是把資料拆小,而是讓 Shopify 平台上的排程、客群、疊加規則與啟用狀態,和後台看到的活動建立一對一關係。
上一篇談的是直接改價活動的 Snapshot 與安全回復,這篇回到由 Shopify Function 執行的 Automatic App Discount。
原本的做法與問題症狀
舊架構大致是:
多個 Promotion
↓
一份 shop-level JSON 設定
↓
一個 Automatic App Discount
↓
一個 Discount Function 處理全部規則
這個做法的優點是 Shopify 端只有一個資源,建立與更新都很簡單。但 Automatic App Discount 本身擁有的欄位包括:
startsAt與endsAtcontextcombinesWith- active/deactivated 狀態
- function-owner metafield
當多個 Promotion 共用同一個 node,這些欄位只能有一組值。
例如活動 A 只開放特定 Customer Segment,活動 B 則適用所有顧客;如果兩者放在同一個 node,就無法同時把平台層的 context 設成兩種狀態。
折扣疊加也一樣。活動 A 可以和 order discount 並用,活動 B 不允許,但共用 node 只有一份 combinesWith。最後只能把差異塞進 Function 自己判斷,或犧牲其中一個活動的設定。
排程則更容易出現邊界問題。舊版會從所有 rules 算出最早開始與最晚結束時間,node 在整段期間都保持啟用,再由 Function 過濾個別 rule。這使 Shopify Admin 顯示的生命週期和商家後台的 Promotion 不一致。
問題是怎麼推敲出來的?
真正要先回答的問題不是「一個 JSON 要放幾條規則」,而是「誰擁有 Promotion 的生命週期」。
Shopify Discount Function 執行時,會收到擁有它的 discount node。context、discount classes、combination settings 與 schedule 都是 node 的平台設定,不是一般 Function JSON 裡的任意欄位。
因此,如果 Promotion 需要獨立控制以下任一項目,它就已經接近一個獨立的 Shopify discount:
| Promotion 設定 | Shopify 對應位置 |
|---|---|
| 開始與結束時間 | Automatic App Discount schedule |
| 全部顧客或特定客群 | discount context |
| 可和哪些折扣並用 | combinesWith |
| 啟用或停用 | discount node status |
| Function runtime 規則 | function-owner metafield |
原始碼演進也驗證了這一點。同步程式原本只保存 config-level discountNodeId;後來改成每個 Promotion 各自保存 discountNodeId,同步時逐筆建立或更新對應的 Automatic App Discount。
Shopify 目前也明確說明,一個 Function invocation 處理一個 automatic 或 code discount。多個啟用中的 discount functions 會平行執行,彼此不知道對方的結果,最後再依 discount node 的 combination rules 決定如何套用。
最後採用的架構
新的資料流改成:
Promotion A ── discountNodeId A ── Automatic App Discount A
Promotion B ── discountNodeId B ── Automatic App Discount B
Promotion C ── discountNodeId C ── Automatic App Discount C
↓
共用同一版 Function code
共用的是 Function extension,不是 function-owner 的設定。每個 discount node 都有自己的:
- 標題與排程。
- Customer Segment context。
- Product、Order 與 Shipping discount combination settings。
- 精簡 Function metafield。
- 啟用狀態。
後台完整設定仍保存在 shop-level metafield,讓管理畫面與 Theme Extension 使用;真正交給 Checkout 的 runtime config 則由每個 Promotion 個別產生。
這讓「後台的一個活動」和「Shopify Admin 的一個自動折扣」可以彼此追蹤。更新活動時使用既有 node ID 執行 update;第一次建立才 create;停用活動則 deactivate,而不是把整批 Promotion 一起關閉。
關鍵實作
同步時先把既有 Promotion ID 與 discount node ID 建成對照,再逐筆處理:
for (const promotion of promotions) {
const runtimeConfig = buildPromotionSyncConfig(promotion);
const result = await syncPromotionDiscount(runtimeConfig);
syncedPromotions.push({
...promotion,
discountNodeId: result.discountNodeId,
});
}
每一筆 Automatic App Discount input 都能帶入自己的平台設定:
{
title: promotion.title,
discountClasses: ['PRODUCT'],
combinesWith: promotion.combinesWith,
context: promotion.customerSegmentIds.length
? {customerSegments: {add: promotion.customerSegmentIds}}
: {all: 'ALL'},
startsAt,
endsAt,
metafields: [runtimeConfiguration]
}
如果 Promotion 已有 discountNodeId,同步程式會先查 node 的實際型別。仍是 DiscountAutomaticApp 就 update;若連到不同的舊 automatic discount 型別,先刪除不相容資源,再建立正確的 app discount。
從舊版 config-level node 遷移時,程式先建立所有 per-promotion nodes,成功後再停用舊 node,最後才保存新的 shop-level 設定。這可避免新舊折扣在遷移完成後同時保持啟用。
實際驗證
本次測試涵蓋的不只是「建立 mutation 有被呼叫」:
- 兩個 Promotion 會建立兩個不同的 Automatic App Discount。
- 每個 Promotion 都保存自己的
discountNodeId。 - 個別
combinesWith會進入對應 node。 - 全部顧客與 Customer Segment 會轉成不同的
context。 - Function metafield 每次只包含該 Promotion 的 runtime rule。
- 停用 Promotion 會呼叫 automatic discount deactivate。
- 舊 config-level discount 會在新 nodes 同步後被停用。
- Shopify discount 同步失敗時,不保存新的 storefront config。
- 刪除 Promotion 前先停用它所連結的 discount node。
這些測試把本地設定、Shopify 資源與 Function runtime config 當成三個需要一起驗證的狀態。
仍然存在的限制
一對一架構會增加啟用中的 Function discounts 數量。Shopify 目前限制每間商店最多啟用 25 個 discount functions,因此不能把 Promotion 數量視為無上限。若產品需要數百個活動,就要重新評估合併規則、排程區間與執行模型。
同步也不是跨 Shopify API 與 shop metafield 的 transaction。假設前三個 nodes 已建立,第四個建立失敗,而新的 node IDs 尚未保存,重試時仍可能產生需要人工整理的資源。正式系統應加入 reconciliation query、冪等識別或可續跑進度。
目前管理介面的「刪除 Promotion」會先停用遠端 discount,再移除本地設定。這能避免活動繼續套用,卻不等於平台資源已被永久刪除。若要完整 garbage collection,還需要保留遠端 ID、執行 delete,並處理刪除失敗後的重試。
最後,combination settings 只是宣告這個 discount 願意和哪些 discount classes 並用。另一個折扣也必須允許對應組合,Cart 條件也要同時成立,不能把 checkbox 當成「強制疊加」。
可以延伸到哪些情境?
這個案例可以整理成一個資源建模原則:當本地物件需要獨立的排程、權限、狀態或平台設定時,就不要只因為它們共用程式碼而強迫共用遠端資源。
同樣的判斷可以用在:
- Webhook subscription 與不同事件來源。
- Metaobject entry 與各自發布狀態。
- Fulfillment task 與外部工作單。
- Billing entitlement 與不同功能方案。
下一篇會把 Promotion 建立後的顯示與交易路徑接起來:Shopify 折扣如何同時支援商品頁、購物車、Checkout 與 POS?
