当订阅遇上套装:Cart Transform 的 selling-plan 高墙
Cart Transform API 文档写得很直白:只要存在 selling plan(订阅方案),lineExpand、linesMerge、lineUpdate 统统拒绝——订阅就是不被支持。于是套装(bundle)应用只能拆成两条流。而一份社区 bug 报告称,就在拆流之后,selling-plan 的价格还会泄漏进错误的购物车行。本文讲清楚这堵墙、这次泄漏,以及能让你的套装价格计算无论如何都不算错的输入处理写法。
TL;DR: 只要购物车里有 selling plan(订阅方案),Shopify 会拒绝所有 Cart Transform 操作(lineExpand、linesMerge、lineUpdate)——订阅在官方兼容表里的状态就是 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,这些操作就会被拒绝。同一页的兼容性表把墙画得更完整:
- Cart(购物车):支持。B2B:支持。Draft Order(草稿单):支持。
- Checkout(结账):部分支持——脚注说的就是 selling-plan 拒绝。
- Subscription(订阅/周期性订单):不支持。
- Order Edit(订单编辑)、Create Order API、Pre-order / Try Before You Buy:不支持。
同一页还有两条约束,直接决定套装架构:
- 每个 app 每家店最多装一个 cart transform function——但如果多个 app 各装了一个,它们全都会跑。你的操作会和商家装的其他套装/加购 app 共享同一个购物车。
lineUpdate(改行的价格/标题/图片)只有开发店和 Shopify Plus 能用。非 Plus 的生产店,你手里只有 expand 和 merge。
所以社区那位开发者的拆流不是风格选择,而是平台允许的唯一架构:
| 套装类型 | 用哪个 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 响应对待,别当真理对待。
文档确实定义了的,是购物车行输入上的这些字段:
cost.subtotalAmount——「应用任何折扣之前的商品成本」。是折扣计算的起点,按 variant 定价。cost.amountPerQuantity——单位成本。sellingPlanAllocation——带 selling plan 的行上存在,包含priceAdjustments(逐项价格调整明细)和perDeliveryPrice(单次配送的有效价格;文档原例:6 次配送共 $48.00 → 每次 $8.00)。
一行如果有 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 里。
最小复现
在开发店复现这堵墙——以及(如果还能复现)这次泄漏:
- 建一个 selling plan group 并挂到 variant V 上。建一个一次性套装 offer 和一个订阅套装 offer,都包含 V。
- 把 V 加进购物车两次:一次带 selling plan,一次不带。
- 从 Cart Transform 尝试
lineExpand或linesMerge:观察拒绝——这是文档行为——因为存在 selling plan。 - 在 Discount function 里打出 V 的一次性行的
cart.lines[*].cost.subtotalAmount,和 V 的正常价格对比。如果 field report 仍能复现,一次性行会显示 selling-plan 价格。有了复合键分组,你的计算两种情况下都不受影响。
第 3 步是文档事实。第 4 步是 field report——打日志,自己看。
检查清单:值得钉在工位上方的数字
- 存在 selling plan ⇒
lineExpand、linesMerge、lineUpdate全部拒绝;Cart Transform 对订阅完全不支持。 - 执行顺序:Cart Transform(1)→ Discount(2、5)。Discount 看到的是 transform 后的购物车。
- 每 app 每店一个 cart transform;多个 app 的 transform 全部都会跑。
lineUpdate:仅开发店和 Plus。- 官方指南的套装上限:150 个组件、3 个选项、不允许嵌套套装;套装组件只有所属 app 能管理。
- 输入查询:3,000 字节、成本 30、列表 ≤ 100 元素;配置 metafield 超过 10,000 字节整个不返回。
- 购物行按「variant + sellingPlan」复合键分组;订阅价格从
sellingPlanAllocation.perDeliveryPrice读,永不读cost。
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),在正文中出现处均已标注。