# 构建商品同步应用

> 让外部系统与 Shoplazza 商品库保持同步：安装时全量回补、订阅商品 Webhook、验签后拉取完整商品详情。

让外部系统与店铺商品保持同步：安装时先拉取现有商品，之后通过 webhook 实时接收变更，再用 Admin API 拉取完整商品数据并持久化。

## 本指南涵盖

- 订阅商品 webhook（`products/create`、`products/update`、`products/delete`）
- 安装时全量拉取店铺现有商品
- 校验 webhook 签名并识别来源店铺
- 通过 Admin API 拉取完整商品详情并持久化

## 适用场景

当外部系统（如 ERP、选品工具、数据分析平台）需要维护一份店铺商品的实时镜像时，使用本方案。

## 商品同步的工作原理

完整的同步分两个阶段：

- **安装阶段（一次性）**：OAuth 之后，注册商品 webhook 并全量拉取店铺**现有**商品。webhook 只推送**订阅之后**发生的变更，不做全量，已存在的商品永远不会进来。
- **运行阶段（持续）**：之后每次新建、修改、删除都会以 webhook 推送给你，你再拉取完整商品并持久化。

流程图如下：

![商品同步流程：安装阶段注册 webhook 并全量拉取，运行阶段对每次变更验签、拉取详情并持久化](https://cnres.appracle.com/1d86dbdcbb7bb9e57e8abfbf1b68fbc3.png)

应用把同步到的商品持久化，并可以展示出来：

![已同步商品在应用中展示](https://cnres.appracle.com/ea12c5229a87c107225afd9def1298e5.png)

:::note
本指南全程使用 API 版本 `2026-01` 与 Node.js。下面的函数都是普通辅助函数，每一步都标注了在哪里调用。
:::

## 前置需求

- 一个已跑通 OAuth 与 token 存储的公有应用——请先完成[开发独立应用](/docs/app/getting-started/create-public-app/develop-app)。下面的每个请求都带 `Access-Token: {token}` 头，用你为每个店铺保存的 `access_token`。
- OAuth 授权时已获得 `product` 访问权限。

## 第一步：订阅商品 webhook

**在哪运行**：在 OAuth 安装回调里，保存好该店铺 token 之后。

为每个商品事件各注册一条订阅——对 `products/create`、`products/update`、`products/delete` 各调用一次。请求体把订阅信息包在 `webhook` 对象里。

`POST /openapi/2026-01/webhooks`

请求体：

```json5
{
  "webhook": {
    "topic": "products/create", // 每个 topic 各调用一次
    "address": "https://your-app.example.com/webhook/products-create"
  }
}
```

返回创建的订阅对象。

:::note
注册订阅时有几点要注意：

- **`address` 必须是公网 HTTPS URL。** 本地开发时，用内网穿透工具（如 ngrok）把本地服务暴露到公网。
- **请求体结构因 API 版本而异。** 从 `2026-01`（以及 `2025-06`）起，请求体必须把订阅包在 `webhook` 对象里（如上）；`2022-01` 用扁平的 `{ "topic", "address" }`。详见 [Webhook 概述](/docs/app/building-blocks/webhooks/overview)。
- **对同一 topic 和 address 重复注册会产生重复订阅。** 如果每次安装都注册，先用 `GET /openapi/2026-01/webhooks` 查出已有订阅，删掉旧的 `products/*` 再注册。
:::

## 第二步：初始全量同步

**在哪运行**：在同一个安装回调里，紧接 `registerProductWebhooks` 之后。

用列表接口拉取当前全部商品。该接口用游标分页——传 `per_page`（最大 250），从第二页起再带上一页返回的 `cursor`。

`GET /openapi/2026-01/products`

响应（节选）：

```json
{
  "code": "Success",
  "data": {
    "cursor": "MjAyNi0wMS0yM1QxMDowODoyN1o...",
    "products": [
      {
        "id": "ac7783d3-df2d-47fe-abca-7ee3d9bceb04",
        "title": "T-shirt",
        "handle": "t-shirt",
        "inventory_quantity": 666,
        "price_min": 11,
        "price_max": 33,
        "created_at": "2026-07-01T09:25:50Z"
      }
    ]
  }
}
```

完整字段见 [商品列表](/api/products)。

全量拉取时，逐页翻这个接口：第一页不带 `cursor`，把每页返回的商品逐个交给你自己的 `upsertProduct`（见第五步），再用返回的 `data.data.cursor` 请求下一页；某页为空或不再返回 cursor 时停止。

## 第三步：接收并校验 webhook

**在哪运行**：作为应用里的一个公网 HTTP 路由——它是所有运行期变更的入口。

信任请求体之前，先对**原始请求体**校验签名，再从请求头读取来源店铺和事件类型：

```javascript
const express = require('express');
const crypto = require('crypto');
const app = express();

// 对「原始请求体」校验 webhook 签名。
function verifyWebhook(rawBody, hmacHeader, clientSecret) {
  const digest = crypto
    .createHmac('sha256', clientSecret)
    .update(rawBody)
    .digest('base64');
  return crypto.timingSafeEqual(Buffer.from(digest), Buffer.from(hmacHeader));
}

// 每个事件一个路由。express.raw() 保留原始字节，签名校验需要它——
// 这个路由上不要用 JSON 解析器。
app.post('/webhook/:event', express.raw({ type: '*/*' }), async (req, res) => {
  const hmac = req.get('X-Shoplazza-Hmac-Sha256');
  if (!verifyWebhook(req.body, hmac, CLIENT_SECRET)) {
    return res.sendStatus(401); // 校验不过一律拒绝
  }

  // 从请求头识别来源店铺和事件，而不是从 URL 或 body。
  const shop = req.get('X-Shoplazza-Shop-Domain');
  const topic = req.get('X-Shoplazza-Topic');
  const dedupId = req.get('X-Shoplazza-Deduplication-ID');
  const { product } = JSON.parse(req.body.toString('utf8'));

  // 幂等：同一事件可能被投递多次——已处理过就跳过。
  if (await alreadyProcessed(dedupId)) {
    return res.sendStatus(200);
  }

  // ... 处理事件：见第四步（拉取）和第五步（持久化）...
  res.sendStatus(200);
});
```

:::warning
必须对**未解析的原始请求体**校验。如果先经过 JSON 解析（例如全局 `express.json()`），字节会改变，校验必然失败。只在 webhook 路由上用 `express.raw()`。
:::

:::note
每个 webhook 请求都带有标识用的请求头，你不需要在 URL 里编码任何信息：

- `X-Shoplazza-Shop-Domain`——事件来自哪个店铺，用它查出该店铺的 token
- `X-Shoplazza-Topic`——事件名，如 `products/create`
- `X-Shoplazza-Deduplication-ID`——事件唯一 ID。同一事件可能被投递多次，已处理过的 ID 应跳过。
:::

payload 把商品包在 `product` 里，其中只有 `id` 一定有用：

```json
{ "product": { "id": "0510dace-34da-4555-a896-495e0cb1ed6e" } }
```

完整 payload 见 [Products create](/docs/app/building-blocks/webhooks/supported-webhook-events/products/create) 参考。

## 第四步：拉取完整商品详情

**在哪运行**：在 webhook 处理函数内部，处理 `create` 和 `update` 事件时。

webhook payload 只是快照。用商品 id 拉取权威详情。

`GET /openapi/2026-01/products/{id}`

商品在 `data.data.product` 下返回，包含 `options`、`images`、`variants`、`primary_image`。完整字段见 [获取商品](/api/product-detail)。第五步以 `fetchProduct(shop, accessToken, productId)` 调用这次拉取。

## 第五步：持久化到数据存储

**在哪运行**：在同一个 webhook 处理函数里，替换第三步里的占位注释。

查出该店铺保存的 token，再以 `product.id` 为键做 upsert 或删除，并记下事件 ID 以便跳过重复投递。`getToken`、`upsertProduct`、`deleteProduct`、`alreadyProcessed`、`markProcessed` 都是你自己的存储层函数（token 存储来自前置，商品表与去重表是你自己的）：

```javascript
const accessToken = await getToken(shop); // 安装时为该店铺保存的 token

if (topic === 'products/delete') {
  await deleteProduct(shop, product.id); // delete 的 payload 带 id
} else {
  // create/update：拉取完整商品，再以 id 为键 upsert
  const full = await fetchProduct(shop, accessToken, product.id);
  await upsertProduct(shop, product.id, full);
}

await markProcessed(dedupId); // 记下来，下次重复投递就会被跳过
res.sendStatus(200);
```

:::note
`alreadyProcessed` 和 `markProcessed` 是你自己的存储函数，用来记录已处理的 `X-Shoplazza-Deduplication-ID`——同一事件可能被投递多次。只有持久化成功后才返回 `2xx`；返回 `5xx` 会触发重试。
:::

## 下一步

- [Webhook 概述](/docs/app/building-blocks/webhooks/overview)
- [API 参考](/api/openapi)
