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

> 用 Ajax API 把买家在商品页填的刻字等信息带进 Shoplazza 购物车，一路带到订单，再从订单读回来。

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

## 背景

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

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

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

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

## 工作原理

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

```mermaid
flowchart LR
    A["加购<br/>POST /api/cart"] --> C["购物车行项目<br/>properties"]
    P["补写<br/>PATCH /api/cart/:variant_id"] --> C
    B["立即购买<br/>POST /api/checkout/order"] --> D[结账会话]
    C --> D
    D --> E["订单行项目<br/>custom_properties"]
    E --> F[订单 API]
    E --> G["orders/create webhook"]
```

## 判断你的业务该怎么用

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

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

## 前置条件

- 一个主题应用扩展——放在购买区的 App Block，或注入脚本的 App Embed Block 都可以。见 [开发主题扩展](/docs/app/extensions/online-store/add-theme-extension)。
- 了解 [购物车 Ajax API](/docs/theme/references/ajax-api/cart)。下面所有前台调用都用 `window.SHOPLAZZA.routes.root` 拼 URL，多语言店铺也能正常工作。
- 仅第 5 步和第 6 步需要：一个已完成 OAuth 授权与 token 存储的应用——见 [开发独立应用](/docs/app/getting-started/create-public-app/develop-app)——并具有 `order` 访问权限范围。

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

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

```javascript
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' }
  })
});
```

用 [获取购物车](/docs/theme/references/ajax-api/cart#get-localeapicart) 读回购物车，能看到这一行实际的存储形态：

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

:::note
购物车里 `properties` 返回的是 **JSON 字符串**而不是对象，读键之前要先解析。参数说明见[添加单个规格到购物车](/docs/theme/references/ajax-api/cart#post-localeapicart)参考页。
:::

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

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

```javascript
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; });
```

响应（节选）：

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

与购物车接口一样，[创建结账会话](/docs/theme/references/ajax-api/checkout#post-localeapicheckoutorder) 的文档没有写 `line_items[]` 上的 `properties`，但接口接受它。不要传价格——服务端按变体定价。

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

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

监听前台的 `dj.addToCart` 事件，查到那一行，合并后用 [设置单个行项目数量](/docs/theme/references/ajax-api/cart#patch-localeapicartvariant_id) 写回。

```javascript
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
        })
      });
    });
});
```

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

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

:::note
监听 `dj.addToCart`，**不要**监听 `dj.cartChange`。`dj.cartChange` 在 `PATCH` 和 `DELETE` 时也会触发，所以在 `dj.cartChange` 里改购物车的处理函数会无限触发自己。两个事件的说明见 [主题事件](/docs/theme/theme-features/tracking-and-analytics/theme-events)。
:::

## 第 4 步：把属性对顾客隐藏 {#hide-from-shopper}

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

```javascript
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`**，而且已经是对象——见 [查询订单详情](/api/order-detail)。

```json
{
  "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[]` 上，后端无需轮询就能响应新订单——见 [监听订单事件](/docs/app/use-cases/orders-and-fulfillment/listen-for-order-events)。

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

## 第 6 步（可选）：按属性查订单

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

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

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

```javascript
// 在你的 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/order-update)。注意这里不对称——标签**传的时候是数组**，**读回来是逗号分隔的字符串**。

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

- API：用 [查询订单列表](/api/orders)，精确匹配传 `?order_tags=exp:demo_hero:A`，前缀匹配传 `?fuzzy_fields=tag_list&fuzzy_keywords=exp:`。
- 后台：订单列表 → 搜索类型选**订单标签** → 输入标签。标签列可在**编辑表头**里勾出来。

## 验证

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

## 应用示例

[按尺寸计价销售商品](/docs/app/use-cases/product-merchandising/sell-by-measurement) 把上面这些全用上了：顾客填宽和高，块把 `Width` / `Height` / `Area` 写成可见键、把计价单位写成隐藏键，订单把它们带到工厂。它还展示了顾客的输入必须影响价格时该怎么做——这是属性本身做不到的。

## 下一步

- [购物车 Ajax API](/docs/theme/references/ajax-api/cart)
- [结账 Ajax API](/docs/theme/references/ajax-api/checkout)
- [主题事件](/docs/theme/theme-features/tracking-and-analytics/theme-events)
- [监听订单事件](/docs/app/use-cases/orders-and-fulfillment/listen-for-order-events)
