前言

最近開發一個 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 層。最後形成一個很難察覺的狀態:

  1. Admin API 接受 metafield 寫入。
  2. 後台重新讀取設定也正常。
  3. Automatic App Discount 顯示啟用。
  4. Function input 中的 metafield 卻變成空值。
  5. 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 分開。

比較穩定的順序是:

  1. 先定義 Function 真正需要的最小 contract。
  2. 對 JSON 使用 byte 而不是字元數檢查。
  3. 在寫入前檢查 list、query cost 與 input 限制。
  4. 能在 runtime 查詢的 membership,不預先展開成巨大清單。
  5. 用 schema version 管理部署與既有設定 migration。
  6. 測試 Function 實際收到的 input,不只測 Admin mutation。

下一篇會接著處理另一個表面簡單、實際跨越瀏覽器與商店設定的問題:開發 Shopify 排程促銷時,為什麼時間會差 8 小時?

參考資料