前言
最近開發一個 Shopify 折扣 App 時,商家可以替商品或商品系列建立百分比促銷,再由 Discount Function 於 Cart 與 Checkout 計算實際折扣。
功能剛完成時,小型促銷都能正常運作;選擇較大的商品系列後,後台仍顯示儲存成功,Automatic App Discount 也確實存在,結帳卻突然不再套用折扣。
這種問題最麻煩的地方是,它不像一般 GraphQL mutation 會直接回傳錯誤。真正失效的是後續的 Function input:設定可以寫入 Shopify metafield,不代表執行 Function 時一定讀得到。
原本的做法與問題症狀
第一版把後台使用的完整促銷模型直接序列化成 JSON,寫到 Automatic App Discount 的 function-owner metafield。內容包括:
- Promotion 與 Rule 的標題及識別欄位。
- 直接選擇的 Product GID。
- 為商品頁顯示準備的 product handle。
- Collection GID。
- 儲存當下從 Collection 展開的全部商品。
- 排程、POS 與其他 UI 狀態。
這個設計一開始很自然:後台已經有一份完整設定,Function 也需要規則,直接共用同一個資料結構最快。
但商品系列只要包含數百個商品,每條規則就會累積大量 GID 與 handle。同一批資訊還可能同時出現在 Promotion 與 Rule 層。最後形成一個很難察覺的狀態:
- Admin API 接受 metafield 寫入。
- 後台重新讀取設定也正常。
- Automatic App Discount 顯示啟用。
- Function input 中的 metafield 卻變成空值。
- Function 因設定無效而回傳空操作,結帳看起來就像「折扣失靈」。
問題是怎麼推敲出來的?
排查時需要先分清楚三種不同限制:
| 層次 | 這次相關的限制 |
|---|---|
| 一般 metafield 儲存 | JSON metafield 本身可以存放比 Function 可讀範圍更大的值 |
| Function input query metafield | 超過 10,000 bytes 的值不會回傳給 Function |
| Function 整體 input | 200 個 cart lines 以內的動態 input 上限是 128 kB |
容易誤判的地方是:既然 Function input 可以到 128 kB,為什麼一份不到這個大小的設定仍然消失?
原因是 128 kB 是完整 Function input 的上限,function-owner metafield 還有獨立的 10,000-byte 限制。後者先被觸發時,Function 根本收不到那個 metafield。
另外,input query variables 的 GraphQL list 也不能超過 100 個元素。因此不能只把 Collection ID 從規則移到 variable,卻沒有替數量設上限。
最後確認問題的方法不是只看字串長度,而是使用:
Buffer.byteLength(JSON.stringify(functionConfig), 'utf8');
JavaScript 的 string.length 計算的是 UTF-16 code units,不等於 UTF-8 bytes。設定中如果包含中文標題,只看字元數會低估實際 payload。
最後採用的架構
修正後把「後台資料」與「Function runtime contract」分成兩份模型。
後台仍可保存完整 Promotion、選項名稱與顯示資料;寫進 Automatic App Discount 的設定則改成精簡 schema,只保留:
- Promotion ID、標題與 active 狀態。
- 一條真正屬於這個 discount node 的 runtime rule。
- 百分比。
- 直接選取的 Product GID。
- Collection GID。
- 必要排程與 runtime targeting 欄位。
schemaVersion。
同時刪除 Function 不需要的 product handle、重複 Promotion 陣列與其他 UI-only 欄位。因為架構已經是「一個 Promotion 對應一個 Automatic App Discount」,每個 function owner 沒必要再保存整份 Promotion 清單。
在送出 GraphQL mutation 前,伺服器會先做兩個 fail-fast 檢查:
if (collectionGids.length > 100) {
return {ok: false, error: '選取的商品系列超過執行限制'};
}
const value = JSON.stringify(runtimeConfig);
if (Buffer.byteLength(value, 'utf8') > 10_000) {
return {ok: false, error: '促銷規則過大,請改用商品系列'};
}
直接選擇大量商品仍會占用 Product GID 空間,因此錯誤訊息會引導商家改用 Collection,而不是讓儲存表面成功、等到 Checkout 才無聲失敗。
關鍵實作
Collection 不再於儲存時展開成全部商品,而是把 Collection GID 放在 function-owner JSON metafield 的頂層,作為 input query variable:
query Input($collectionGids: [ID!] = []) {
cart {
lines {
merchandise {
... on ProductVariant {
product {
inSelectedCollections: inAnyCollection(ids: $collectionGids)
}
}
}
}
}
}
Function extension 再指定 variable 要讀取哪個 function-owner metafield。這樣 JSON 的同一份精簡資料既能提供 runtime rule,也能替 GraphQL argument 填入 Collection ID。
Function 收到每條 cart line 後,只需要判斷:
直接選取的 Product ID 符合
或
inAnyCollection 回傳 true
這比把整個 Collection 商品清單塞進 JSON 更小,也會在結帳時計算當下的 Collection membership。
schemaVersion 則讓 Function 可以辨識新舊設定。部署新 Function 後,既有 discount nodes 仍要重新同步設定;不能只部署 Web App,就假設 Shopify-hosted Function 與既有 owner metafield 已一起更新。
實際驗證
這次加入的測試不只確認 mutation 成功,而是直接檢查 runtime contract:
- 產生的 JSON 只包含精簡 schema 欄位。
productHandles不再進入 Function 設定。- 每個 function owner 只保存自己的 Promotion rule。
- 未來排程的 Collection 規則仍被保留。
- Collection 不再展開成大量 Product GID。
collectionGids能提供 input query variable。- 產生的 UTF-8 JSON 小於 10,000 bytes。
- 超過 100 個 Collection 時,在寫入前回傳明確錯誤。
- Function fixture 能以
inAnyCollection的布林結果命中折扣。 - 舊版 POS 設定在 schema migration 期間仍有相容測試。
這些測試把「Admin API 寫入成功」和「Function 在 runtime 收得到並能執行」視為兩個不同的驗收點。
仍然存在的限制
精簡 schema 不是無限容量。大量直接選取的商品仍然會累積 GID,input query variable 也有 100 個 Collection 的 list 限制。
inAnyCollection 只回傳這個商品是否屬於指定集合之一。如果未來每個 Collection 需要不同折扣率,就不能只使用單一布林 alias,必須重新設計變數與 membership 結果。
此外,Function 設定格式變更同時牽涉:
- Web App 產生 metafield 的程式。
- Shopify-hosted Function 的 GraphQL input query。
- Function runtime 對 schema version 的解析。
- 已存在 discount nodes 的設定重同步。
只完成其中一個部署面,正式商店仍可能執行舊 contract。
可以延伸到哪些情境?
這次經驗不只適用折扣。任何 Shopify Function 需要由商家設定大量商品、Collection、Tag 或條件時,都應先把 UI model 與 runtime model 分開。
比較穩定的順序是:
- 先定義 Function 真正需要的最小 contract。
- 對 JSON 使用 byte 而不是字元數檢查。
- 在寫入前檢查 list、query cost 與 input 限制。
- 能在 runtime 查詢的 membership,不預先展開成巨大清單。
- 用 schema version 管理部署與既有設定 migration。
- 測試 Function 實際收到的 input,不只測 Admin mutation。
下一篇會接著處理另一個表面簡單、實際跨越瀏覽器與商店設定的問題:開發 Shopify 排程促銷時,為什麼時間會差 8 小時?
