Shopify functions

当订阅遇上套装:Cart Transform 的 selling-plan 高墙

Cart Transform API 文档写得很直白:只要存在 selling plan(订阅方案),lineExpand、linesMerge、lineUpdate 统统拒绝——订阅就是不被支持。于是套装(bundle)应用只能拆成两条流。而一份社区 bug 报告称,就在拆流之后,selling-plan 的价格还会泄漏进错误的购物车行。本文讲清楚这堵墙、这次泄漏,以及能让你的套装价格计算无论如何都不算错的输入处理写法。

TL;DR: 只要购物车里有 selling plan(订阅方案),Shopify 会拒绝所有 Cart Transform 操作lineExpandlinesMergelineUpdate)——订阅在官方兼容表里的状态就是 Not supported。套装应用唯一可行的架构是拆两条流:一次性套装走 Cart Transform,订阅套装走 Discount function(执行顺序上 Cart Transform 先跑、Discount 后跑)。另外根据一份社区 field report(实踩报告):同一 variant(商品规格)同时以订阅行和普通行存在于购物车时,普通行的 cost.subtotalAmount 会带上 selling-plan 价格。防御方法:按「variant + sellingPlan」复合键给购物行分组,订阅价格只从 sellingPlanAllocation 读,永远不碰 cost

本文是《Shopify Functions 生产实战》系列第 4 篇。第 1 篇讲折扣码冲突,第 2 篇讲 10,000 字节 metafield 陷阱,第 3 篇讲 tag 上限。这一篇讲两个平台功能——套装和订阅——在同一个购物车里相撞时会发生什么。

事发经过

一位开发者在 Shopify 社区发了一份隔离得很干净的 bug 报告。他在做套装应用:一次性套装走 Cart Transform function(用 _easyBundle:OfferId 这类 line-item property 做标记路由);订阅套装走 Discount function——因为,这就是第一堵墙,Cart Transform 根本不能碰带 selling plan 的行

他的架构是「文档意义上正确」的那种:两条独立的流,因为平台逼你这么拆。然而:同一 variant 在购物车里出现两次——一行是订阅行、一行是普通一次性行——普通行在 Function 输入里的 cost.subtotalAmount 竟然是 selling-plan 价格。基于这个 cost 算出来的套装价格自然是错的。另外两位社区成员看了证据后认同:这很像是 variant 级价格解析在 Cart Transform 拿到输入之前就发生了泄漏。

我们在生产环境跑着同样的双流架构。这篇文章就是这堵墙的地图、墙后的 bug 报告,以及无论如何都能算对价格的防御性输入处理。

第一堵墙:selling plan 会直接拒掉 Cart Transform 操作

Cart Transform API 参考文档在 Invalid scenarios(无效场景)一节写得异常直白:

“Shopify rejects lineExpand, linesMerge, and lineUpdate operations if a selling plan is present.” (只要存在 selling plan,Shopify 即拒绝 lineExpand、linesMerge、lineUpdate 操作。)

注意,不是「拒绝针对订阅行的那个操作」——只要购物车里存在 selling plan,这些操作就会被拒绝。同一页的兼容性表把墙画得更完整:

同一页还有两条约束,直接决定套装架构:

所以社区那位开发者的拆流不是风格选择,而是平台允许的唯一架构:

套装类型 用哪个 Function 原因
一次性套装 Cart Transform(lineExpand / linesMerge 原生套装体验,价格展示由 API 处理
订阅套装 Discount function(cart.lines.discounts.generate.run 一旦有 selling plan,Cart Transform 全部拒绝

Functions 总览页记录的执行顺序让这个架构可行:Cart Transform 先跑(第 1 步:cart lines),Discount function 后跑(第 2、5 步)。 你的折扣逻辑看到的是 transform 展开/合并之后的购物车——对按组件价格求和的订阅套装来说,这正是你要的。

第二堵墙:从墙缝里渗出来的价格

以下是社区 field report(帖子 657784;我们标注为未经确认的平台行为——文档对此没有任何定义):

同一 variant 在购物车出现两次:A 行带 selling plan,B 行不带。Function 输入里,B 行的 cost.subtotalAmount 反映的是 selling-plan 价格,而不是该 variant 的正常价格。任何信任 cost 的套装计算都会被污染。

不管这是下季度会修的 bug,还是 variant 级价格解析的永久边缘行为,教训是一样的,而且可以推广:Function 输入是跨越信任边界的数据。把它当 API 响应对待,别当真理对待。

文档确实定义了的,是购物车行输入上的这些字段:

一行如果有 selling-plan 价格,文档指定的读取位置是 sellingPlanAllocation——不是 cost。而判断一行是不是订阅行的文档方式,就是看它上面有没有 sellingPlanAllocation

防御性写法:复合键,而不是 variant ID

我们审计过的套装计算里(包括短暂地包括我们自己的),根子上的错误都是只按 variant.id 给购物行分组。在购物车里,variant 不是一个可购买的完整身份——variant 加上 selling-plan 上下文才是。想通这一点,修法就是机械的:

# run.graphql —— 查询能区分行的字段,而不只是价格
query Input {
  cart {
    lines {
      id
      quantity
      sellingPlanAllocation {
        sellingPlan { id }
        perDeliveryPrice { amount currencyCode }
        priceAdjustments { price { amount } }
      }
      cost {
        amountPerQuantity { amount currencyCode }
        subtotalAmount { amount currencyCode }
      }
      merchandise {
        __typename
        ... on ProductVariant { id title }
      }
      bundleOffer: attribute(key: "_bundleOfferId") { value }
    }
  }
}
// 按 (variant, sellingPlan) 分组 —— 永不只按 variant 分组
function lineKey(line) {
  const variantId =
    line.merchandise.__typename === "ProductVariant" ? line.merchandise.id : "none";
  const planId = line.sellingPlanAllocation?.sellingPlan?.id ?? "onetime";
  return `${variantId}::${planId}`;
}

function effectiveUnitPrice(line) {
  // 订阅价格的文档指定来源:
  if (line.sellingPlanAllocation) {
    return parseFloat(line.sellingPlanAllocation.perDeliveryPrice.amount);
  }
  // 仅限一次性行:两个池子永远不混
  return parseFloat(line.cost.amountPerQuantity.amount);
}

三条规则由此推出:

1. 永不跨 selling-plan 边界聚合。 一个套装 offer 的所有行必须来自同一个键桶。如果你的 offer 横跨两种,那是两个 offer。

2. 订阅价格只从 sellingPlanAllocation 读。 如果 field report 里的价格泄漏是真的,这个写法天然免疫——你从不在订阅行上读 cost,也永远不让订阅行的价格污染一次性行的桶。

3. 输入查询保持小。 输入查询在构建时固定:不含注释最大 3,000 字节,计算成本上限 30,列表参数最多 100 个元素。每个「以防万一」查进来的字段,都是规则变复杂时用不了的预算。运行时会变的配置放进 metafield JSON blob——但要小于 10,000 字节,因为超限的值返回时是整个消失,不是截断(就是本系列第 2 篇讲的静默故障)。

Shopify 官方套装指南还有一句和以上所有内容配套的警告:line-item property「可以被浏览器修改,因此不应依赖它做安全或校验用途」。_bundleOfferId 这类 property 当路由提示没问题;套装的权威定义要放在你自己的 app 拥有的 metafield 里。

最小复现

在开发店复现这堵墙——以及(如果还能复现)这次泄漏:

  1. 建一个 selling plan group 并挂到 variant V 上。建一个一次性套装 offer 和一个订阅套装 offer,都包含 V。
  2. 把 V 加进购物车两次:一次带 selling plan,一次不带。
  3. 从 Cart Transform 尝试 lineExpandlinesMerge:观察拒绝——这是文档行为——因为存在 selling plan。
  4. 在 Discount function 里打出 V 的一次性行cart.lines[*].cost.subtotalAmount,和 V 的正常价格对比。如果 field report 仍能复现,一次性行会显示 selling-plan 价格。有了复合键分组,你的计算两种情况下都不受影响。

第 3 步是文档事实。第 4 步是 field report——打日志,自己看。

检查清单:值得钉在工位上方的数字

Agent 能补上的那一环

拆流架构是一次性设计决策。持续的风险是漂移:商家装了第二个套装 app(记住,每个 app 的 cart transform 都会跑)、订阅 app 开始往你的 offer 用到的 variant 上写 selling plan、或者平台更新改了输入行为让你的套装数学悄悄偏移。这是一个监控循环,正是店铺运营 Agent 能守的事:定期用已知购物车重放你的 function,diff 输出,发现异常就带着证据报给店主——赶在顾客报告套装价格算错之前。


本文已于 2026 年 8 月对照 Shopify 官方文档(Cart Transform Function API、Functions 总览与执行顺序、定制套装指南、Functions 输入查询限制)逐条核验。价格泄漏行为来自社区 field report(Shopify 社区帖子 657784),在正文中出现处均已标注。