跳到主要内容

给购物车和订单添加自定义属性

把顾客在商品页填写的信息——刻字文案、尺寸、礼品留言、内部追踪 id——挂到购物车行项目上,随结账流转到订单,再从订单读回来。到订单为止的部分全部在店铺前台用 JavaScript 和 Ajax API 完成;读回时用 Open API。

背景

商品和变体都是商家提前定义好的。顾客在下单那一刻现填的内容,没有任何字段能装:

  • 个性化内容——刻字、球衣上的名字、礼品留言——在商品页收集完之后无处可去。
  • 尺寸这类定制输入可以显示在页面上,但永远进不了购物车。
  • 你自己系统需要的标识——A/B 实验分组、外部定制器给的设计文件 id——没法钉在这笔购买上。

这些都是同一个缺口:购买链路里没有放顾客自填数据的位置。

行项目属性(properties) 就是这个位置。购物车里的每一行都能带一组键值对,它们随行项目走完结账、落到订单上,并由订单 API 和订单 webhook 返回。本篇的目标是讲清楚怎么写入、顾客和商家会看到什么、怎么读回,以及它做不到什么。

工作原理

  1. 写入。添加单个规格到购物车 里以对象形式传 properties,或在 创建结账会话line_items[] 里传。已经在购物车里的行,用 设置单个行项目数量 更新。
  2. 流转。 购物车把属性存在行项目上,结账时复制到订单行。
  3. 显示。 不带下划线前缀的键会显示在购物车页、结账页、顾客的订单详情和后台订单详情。带下划线前缀的键在所有界面隐藏。
  4. 读回。 订单以 line_items[].custom_properties 暴露它们——通过 查询订单详情orders/create webhook。

判断你的业务该怎么用

四个问题能定下大多数设计:

问题答案
这个值要给顾客看吗?要 → 普通键名(Engraving)。不要 → 下划线前缀(_design_id)。见第 4 步。
它要影响价格吗?属性永远不影响价格。每个选项固定加价,用定制商品应用;价格随尺寸连续变化,见 按尺寸计价销售商品
要按这个值查订单吗?不能直接查——属性不可检索。从你的后端把值复制成订单标签。见第 6 步。
加购那一刻就知道这个值吗?知道就当时传(第 1 步或第 2 步)。之后才决定的,或者顾客用主题原生按钮加购、你没经手请求的,用第 3 步。

前置条件

  • 一个主题应用扩展——放在购买区的 App Block,或注入脚本的 App Embed Block 都可以。见 开发主题扩展
  • 了解 购物车 Ajax API。下面所有前台调用都用 window.SHOPLAZZA.routes.root 拼 URL,多语言店铺也能正常工作。
  • 仅第 5 步和第 6 步需要:一个已完成 OAuth 授权与 token 存储的应用——见 开发独立应用——并具有 order 访问权限范围。

第 1 步:加入购物车时写入属性

在添加行项目的同一个请求里带上 properties。值是一个普通对象,键和值都是字符串。

fetch(window.SHOPLAZZA.routes.root + '/api/cart', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
product_id: '99fa5fb4-f9dd-4db4-998f-011b4d0ff72d',
variant_id: '36e7ca49-343f-4ac2-a2d8-8ef2d8f7a105',
quantity: 1,
properties: {
'Engraving': 'Happy Birthday', // 顾客可见
'_design_id': 'dsg_8f21c' // 隐藏;给你自己的系统读
},
refer_info: { source: 'add_to_cart' }
})
});

获取购物车 读回购物车,能看到这一行实际的存储形态:

{
"cart": {
"line_items": [
{
"variant_id": "36e7ca49-343f-4ac2-a2d8-8ef2d8f7a105",
"quantity": "1",
"properties": "{\"Engraving\":\"Happy Birthday\",\"_design_id\":\"dsg_8f21c\"}"
}
]
}
}
备注

购物车里 properties 返回的是 JSON 字符串而不是对象,读键之前要先解析。参数说明见添加单个规格到购物车参考页。

第 2 步:立即购买时写入属性

立即购买绕过购物车,直接创建结账会话。line_items[] 的每一项都以完全相同的形态接受 properties

fetch(window.SHOPLAZZA.routes.root + '/api/checkout/order', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
refer_info: { source: 'buy_now' },
line_items: [{
product_id: '99fa5fb4-f9dd-4db4-998f-011b4d0ff72d',
variant_id: '36e7ca49-343f-4ac2-a2d8-8ef2d8f7a105',
quantity: 1,
note: '',
properties: {
'Engraving': 'Happy Birthday',
'_design_id': 'dsg_8f21c'
}
}]
})
})
.then(function (res) { return res.json(); })
.then(function (body) { location.href = body.data.checkout_url; });

响应(节选):

{
"state": "success",
"data": {
"order_token": "2446407211694842140211",
"checkout_url": "/checkout/2446407211694842140211"
}
}

与购物车接口一样,创建结账会话 的文档没有写 line_items[] 上的 properties,但接口接受它。不要传价格——服务端按变体定价。

第 3 步:给已在购物车里的行补属性

两种情况用这一步:顾客通过主题原生按钮加购(你根本没经手那次请求);或者值要到行项目存在之后才决定——比如在首页分配的实验分组,要落到顾客之后加的每一行上。

监听前台的 dj.addToCart 事件,查到那一行,合并后用 设置单个行项目数量 写回。

document.addEventListener('dj.addToCart', function (e) {
var variantId = e.detail && e.detail.variant_id;
if (!variantId) return;

var root = window.SHOPLAZZA.routes.root;

// 事件里带 product_id / variant_id / quantity / properties,但没有行项目 id,
// 而 PATCH 需要 id——所以要拉一次购物车找到这一行。
fetch(root + '/api/cart', { headers: { 'Content-Type': 'application/json' } })
.then(function (r) { return r.json(); })
.then(function (data) {
var line = (data.cart.line_items || []).find(function (li) {
return li.variant_id === variantId;
});
if (!line) return;

// properties 是 JSON 字符串。新加的行返回的是字符串 "[]",
// 解析出来是数组。往数组上挂键再 stringify 会把键全丢掉,
// 而 PATCH 照样返回 200。合并前把一切非普通对象的值都收敛成 {}。
var existing = {};
try {
var parsed = JSON.parse(line.properties || '{}');
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) existing = parsed;
} catch (err) { /* 保持 {} */ }

if (existing._experiment_id) return; // 已经打过标

var merged = Object.assign({}, existing, { _experiment_id: 'demo_hero:A' });

return fetch(root + '/api/cart/' + variantId, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
id: line.id, // 行项目 id——必填
product_id: line.product_id,
variant_id: line.variant_id,
quantity: line.quantity, // 原样回传当前数量
properties: merged
})
});
});
});
注意

这次写入有三种静默失败的方式,全都返回 HTTP 200:

  • "[]" 陷阱。 没有属性的行 properties"[]"。往解析出来的数组上合并,stringify 时每个键都会丢。合并前一律先收敛成普通对象,如上所示。
  • 服务端是替换不是合并。 你传的 properties 会成为整个属性集。写之前先读当前值再合并,否则会抹掉别的应用(比如某个商品定制器)已经存在这一行上的数据。
  • 不传 quantity 会改数量。 这个接口是 设置单个行项目数量;把行的当前 quantity 原样回传,否则这一行会被改成别的数量。
备注

监听 dj.addToCart不要监听 dj.cartChangedj.cartChangePATCHDELETE 时也会触发,所以在 dj.cartChange 里改购物车的处理函数会无限触发自己。两个事件的说明见 主题事件

第 4 步:把属性对顾客隐藏

键名以下划线开头,这个属性就是隐藏的。机制只有这一条,而且对所有界面同时生效——购物车页、结账页、顾客的订单详情、后台订单详情,没有按界面单独控制的办法。隐藏属性仍然会被 API 和 webhook 返回,这正是它适合放只有你自己系统要读的值的原因。

properties: {
'Engraving': 'Happy Birthday', // 可见:行项目显示在哪里,它就跟着显示
'_design_id': 'dsg_8f21c' // 隐藏:API 会返回,界面上永不显示
}

可见键的键名就是顾客读到的标签,所以用店铺面向的语言来写。主题按键逐行渲染,所以每个值给一个独立的键,不要把多个值拼进一个字符串。

在后台订单详情里,可见键出现在行项目下面、变体标题和 SKU 之间,而 _design_id 在页面上任何位置都找不到:

Custom Wall Mural
Engraving:Happy Birthday
SKU: MURAL-AREA-0.1M2

第 5 步:从订单读回属性

在订单上,这个字段叫 custom_properties,而且已经是对象——见 查询订单详情

{
"order": {
"number": "JMW07973",
"line_items": [
{
"quantity": 63,
"custom_properties": {
"Width": "205 cm",
"Height": "305 cm",
"Area": "6.25 m²",
"_billing_unit_area": "0.1",
"_billing_units": "63"
}
}
]
}
}

同一个对象也出现在 orders/create webhook 载荷的 line_items[] 上,后端无需轮询就能响应新订单——见 监听订单事件

注意

写的时候叫 properties,读的时候叫 custom_properties。在订单上读 line_items[].properties 什么都拿不到——不报错、也没有值。

第 6 步(可选):按属性查订单

只有当商家需要在后台订单列表里按你的属性值筛单时才做这一步——比如把某个实验分组的订单全捞出来,或者找出带某个设计 id 的所有订单。如果只是从每笔订单上读出这个值(第 5 步)就够了,跳过本步。

之所以要额外做一步:属性不可检索。订单列表的模糊搜索字段覆盖订单号、客户姓名、SKU、标签等二十多项——custom_properties 不在其中,后台的搜索类型下拉里也没有这一项。

绕过去的办法是在订单创建时,从你的后端把值复制成一个订单标签

// 在你的 orders/create webhook 处理函数里。
// 标签是替换不是合并——先读当前值再往里加。
const value = order.line_items
.map(li => li.custom_properties && li.custom_properties._experiment_id)
.find(Boolean);
if (!value) return;

const current = (order.tags || '').split(',').map(t => t.trim()).filter(Boolean);
const tag = 'exp:' + value;
if (current.includes(tag)) return;

await fetch(`https://${shop}/openapi/2026-01/orders/${order.id}`, {
method: 'PUT',
headers: { 'Access-Token': token, 'Content-Type': 'application/json' },
body: JSON.stringify({ order: { tags: [...current, tag] } }) // 传数组
});

完整字段见 更新订单。注意这里不对称——标签传的时候是数组读回来是逗号分隔的字符串

打上标签后,API 和后台都能查到这笔订单:

  • API:用 查询订单列表,精确匹配传 ?order_tags=exp:demo_hero:A,前缀匹配传 ?fuzzy_fields=tag_list&fuzzy_keywords=exp:
  • 后台:订单列表 → 搜索类型选订单标签 → 输入标签。标签列可在编辑表头里勾出来。

验证

  1. 写入——加一行,带一个普通键和一个下划线键。获取购物车 应在 properties 字符串里同时返回两个。
  2. 购物车页——普通键在商品下面单独占一行;下划线键不出现。
  3. 立即购买——同样的两个键通过 创建结账会话 提交,在结账页摘要里以同样方式落地。
  4. 补写——用主题原生按钮加一行,让你的 dj.addToCart 处理函数跑一遍,再 获取购物车。合并进去的键必须存在,且这一行的 quantity 没变。专门在一条新行(属性为 "[]")上试一次。
  5. 订单——完成下单。查询订单详情custom_properties 下返回两个键;后台订单详情只显示普通那个。
  6. 筛选——你的 webhook 给订单打上标签后,?order_tags=<标签> 能返回它,后台在订单标签下搜这个标签也能找到。

应用示例

按尺寸计价销售商品 把上面这些全用上了:顾客填宽和高,块把 Width / Height / Area 写成可见键、把计价单位写成隐藏键,订单把它们带到工厂。它还展示了顾客的输入必须影响价格时该怎么做——这是属性本身做不到的。

下一步