<!-- OPENAPI_CONTRACT_REVISION: 2026-09-21.1 -->
<!-- OPENAPI_CONTRACT_STATUS: released -->
<!-- OPENAPI_SOURCE_SHA256: f32952dbf0d31e28ae1370bfac8bcb93b9a8bc11af6ef27cb04232ffebb3d4cb -->

# 接入指南

## 1. 开始接入前

版本：`1.0.2`

文档状态：正式版。实际接入时，请使用商户提供的接口域名、前台用户 ID、API Key 和商品权限。


接入信息包括：

- 商户的 OpenAPI HTTPS 域名。
- 采购会员在该商户前台的用户 ID。
- 绑定到该前台用户 ID 的 `API Key`。
- 商户实际开放的商品和能力。

接口对接只需要保存三个参数：商户域名、前台用户 ID 和该用户的 API Key。

订单返回的 `allowedActions` 表示当前还能执行哪些操作。列表里没有的操作不要调用。

## 2. 基础约定

基础路径格式：

```text
https://{merchant-domain}/openapi/v1
```

通用规则：

- 编码：UTF-8。
- 请求与响应：`application/json`。
- 时间：使用 10 位 Unix 秒级时间戳，例如 `1785216000`。
- 金额：统一使用人民币元，最多保留两位小数，例如 `100.00` 表示 100 元，`0.01` 表示 1 分。
- 所有编号都应按照字段表标注的 JSON 类型原样保存和传递。
- 字段名：默认使用 `clientOrderRef` 这样的 lowerCamelCase 格式；卡密固定使用 `card_list`、`card_no`、`card_password`。
- 分页：`page` 从 1 开始，`pageSize` 默认 20、最大 100。
- 所有请求必须使用 HTTPS。

### 2.1 编号和返回格式

本文出现的字段就是第三方接入时使用的字段。

- `accountRef`、`catalogRef`、`productRef`、`variantRef` 是整数编号；`platformOrderRef`、`refundRef`、`platformCaseRef` 是字符串编号。请原样保存和传递，不要自行添加前缀或转换类型。
- `clientOrderRef` 由第三方生成，在当前采购账户内不能重复用于另一笔订单，同时也是网络超时后的重试编号。
- 字段名、允许填写的值和数据格式以本文及 OpenAPI YAML 为准。

统一响应：

```json
{
  "code": 0,
  "message": "success",
  "data": {},
  "requestId": "req_01K0EXAMPLE"
}
```

列表数据：

```json
{
  "list": [],
  "total": 0,
  "page": 1,
  "pageSize": 20
}
```

处理响应时要同时检查 HTTP 状态和 `code`。遇到问题请保存 `requestId`，方便商户查询日志。

## 3. 身份验证

商户域名放在请求地址中，前台用户 ID 和 API Key 放在请求头中：

```http
GET /openapi/v1/account HTTP/1.1
Host: merchant.example.com
X-Kys-User-Id: 12345
X-Kys-Key: AK_0123456789abcdef0123456789abcdef
```

规则：

- `X-Kys-User-Id` 必须填写商户提供的采购会员用户 ID。
- `X-Kys-Key` 必须是该用户 ID 在“账户管理 > 接口管理”中启用的 API Key，格式为 `AK_` 加 32 位小写十六进制随机串。
- 商户域名必须使用商户提供的 OpenAPI 主站域名；前台用户 ID 和 API Key 必须属于该商户账户。域名不正确时返回 404，身份参数不匹配时返回 401。
- API Key 重置后，旧 Key 立即失效。
- 不需要 API Secret、时间戳、nonce 或请求签名。
- 用户 ID 和 API Key 不能放在 URL 查询参数或请求体中。
- 所有请求必须使用 HTTPS，API Key 不得写入日志或错误信息。

可以直接运行的多语言示例见第 13 章“身份验证完整示例”。

## 4. 接口清单

第三方可以调用 15 个接口。基础下单接入只需要分类、商品列表、商品详情、商品下单模板、创建订单、订单详情和 Webhook；其他接口按业务需要接入。商品、订单、卡密和售后发生变化时，平台会主动发送 4 类 Webhook：`product.changed`、`order.changed`、`order.credentials.ready`、`aftersale.message.created`。退款结果包含在 `order.changed` 中。

| 方法 | 路径 | 用途 | 接入要求 |
| --- | --- | --- | --- |
| GET | `/account` | 查询账户 | 按需 |
| GET | `/categories` | 查询可发现商品涉及的分类 | 基础 |
| GET | `/goods/catalog` | 查询可采购商品及当前订阅状态 | 基础 |
| GET | `/goods/{productRef}` | 查询商品详情 | 基础 |
| GET | `/goods/tpl` | 查询商品下单模板 | 基础 |
| POST | `/goods/subscriptions` | 批量变更商品订阅 | 需要提前接收商品变化时使用 |
| POST | `/orders/create` | 创建订单并使用余额支付 | 基础 |
| GET | `/orders` | 查询订单列表 | 按需 |
| GET | `/orders/{clientOrderRef}` | 查询订单详情 | 基础兜底 |
| POST | `/orders/{clientOrderRef}/verification-code` | 提交验证码 | 订单要求验证码时使用 |
| POST | `/orders/{clientOrderRef}/cancel` | 申请取消 | 订单允许取消时使用 |
| POST | `/orders/{clientOrderRef}/refunds` | 申请退款 | 订单允许退款时使用 |
| POST | `/aftersales` | 创建售后 | 按需 |
| GET | `/aftersales/{platformCaseRef}` | 查询售后详情 | 按需 |
| POST | `/aftersales/{platformCaseRef}/messages` | 追加售后消息 | 按需 |

以下章节中的路径均省略 `/openapi/v1` 前缀。

每个接口的用途、请求方式、请求地址、全部请求字段、全部返回字段和该接口专属规则都集中写在对应接口章节内。身份验证、金额与时间格式、请求频率限制和通用错误响应属于所有接口共同遵守的规则，只在公共章节说明一次。

## 5. 账户

### 5.1 查询账户

- 请求方式：`GET`
- 请求地址：`/account`

请求字段：无。

返回字段：

| 字段 | JSON 类型 | 必填 | 含义 |
| --- | --- | --- | --- |
| `code` | integer | 是 | 固定为 `0`，表示请求成功 |
| `message` | string | 是 | 本次请求的处理说明 |
| `data` | object | 是 | 当前采购账户资料 |
| `data.accountRef` | integer | 是 | 当前采购会员用户 ID，与请求头 `X-Kys-User-Id` 一致 |
| `data.availableAmount` | number | 是 | 当前可用余额，单位为人民币元，最多保留两位小数 |
| `data.accessState` | string | 是 | `enabled` 表示可正常调用；`suspended` 表示账户已暂停，除本接口外的其他接口都会返回 `CREDENTIAL_SUSPENDED` |
| `requestId` | string | 是 | 本次请求编号，排查问题时提供给平台 |

返回当前账户编号、可用余额和使用状态：

```json
{
  "code": 0,
  "message": "success",
  "data": {
    "accountRef": 1000,
    "availableAmount": 5023.00,
    "accessState": "enabled"
  },
  "requestId": "req_01K0EXAMPLE"
}
```

账户暂停后仍可调用本接口查看余额和状态，但不能继续查询商品、下单或操作订单和售后。

## 6. 分类、商品和订阅

标准接入顺序：

1. 调用 `GET /categories` 获取当前账户可见分类。
2. 选择分类后调用 `GET /goods/catalog?catalogRef=...` 分页查询可采购商品；列表直接返回当前订阅状态和到期时间。
3. 创建本地商品时调用 `GET /goods/{productRef}` 获取详情和规格。
4. 对需要销售的规格调用一次 `GET /goods/tpl`，保存返回的下单模板并生成本地下单表单。单规格商品只传 `productRef`，多规格商品同时传 `variantRef`。
5. 需要在第一笔订单前接收商品变化时，调用 `POST /goods/subscriptions` 订阅商品；不订阅也可以查询模板和创建订单。
6. 成功创建订单后，平台会自动订阅或续期对应商品。后续可以使用 `GET /goods/catalog?catalogRef=...&subscriptionState=subscribed` 查询已订阅商品，并接收这些商品的 `product.changed`。

### 6.1 查询商品分类

- 请求方式：`GET`
- 请求地址：`/categories`

请求字段：无。

返回字段：

| 字段 | JSON 类型 | 必填 | 含义 |
| --- | --- | --- | --- |
| `code` | integer | 是 | 固定为 `0`，表示请求成功 |
| `message` | string | 是 | 本次请求的处理说明 |
| `data` | object | 是 | 分类查询结果 |
| `data.list` | object[] | 是 | 当前账户可见的商品分类数组 |
| `data.list[].catalogRef` | integer | 是 | 分类编号 |
| `data.list[].parentCatalogRef` | integer 或 null | 是 | 上级分类编号；顶级分类为 `null` |
| `data.list[].title` | string | 是 | 分类名称 |
| `data.list[].imageUrl` | string 或 null | 是 | 分类图片公网地址；没有图片时为 `null` |
| `data.list[].displayOrder` | integer | 是 | 显示顺序，数值越小越靠前 |
| `data.list[].revision` | integer | 是 | 分类最后更新时间，10 位 Unix 秒级时间戳 |
| `data.list[].updatedAt` | integer | 是 | 分类最后更新时间，10 位 Unix 秒级时间戳 |
| `requestId` | string | 是 | 本次请求编号，排查问题时提供给平台 |

只返回平台当前显示并且包含授权商品的分类。平台隐藏的分类不会返回：

```json
{
  "code": 0,
  "message": "success",
  "data": {
    "list": [
      {
        "catalogRef": 10,
        "parentCatalogRef": null,
        "title": "话费充值",
        "imageUrl": "https://cdn.example.com/categories/recharge.png",
        "displayOrder": 10,
        "revision": 1785215000,
        "updatedAt": 1785215000
      }
    ]
  },
  "requestId": "req_01K0EXAMPLE"
}
```

### 6.2 查询商品列表

- 请求方式：`GET`
- 请求地址：`/goods/catalog`

按分类查询当前账户可以采购的商品。`catalogRef` 必须使用上一步分类接口返回的值。查询参数：

请求字段：

| 字段 | 位置 | JSON 类型 | 必填 | 含义 |
| --- | --- | --- | --- | --- |
| `page` | Query | integer | 否 | 页码，从 1 开始，默认第 1 页 |
| `pageSize` | Query | integer | 否 | 每页条数，默认 20 条，最多 100 条 |
| `catalogRef` | Query | integer | 是 | 分类接口返回的分类编号 |
| `keyword` | Query | string | 否 | 按商品名称搜索，最多 100 个字符 |
| `availability` | Query | string | 否 | `available` 只查可购买商品；`unavailable` 只查暂不可购买商品 |
| `subscriptionState` | Query | string | 否 | `subscribed` 只查已订阅商品；`unsubscribed` 只查未订阅商品 |
| `updatedAfter` | Query | integer | 否 | 只返回在该时间之后更新的商品，填写 10 位 Unix 秒级时间戳 |

返回字段：

| 字段 | JSON 类型 | 必填 | 含义 |
| --- | --- | --- | --- |
| `code` | integer | 是 | 固定为 `0`，表示请求成功 |
| `message` | string | 是 | 本次请求的处理说明 |
| `data` | object | 是 | 商品分页查询结果 |
| `data.total` | integer | 是 | 符合条件的商品总数 |
| `data.page` | integer | 是 | 当前页码 |
| `data.pageSize` | integer | 是 | 当前每页条数 |
| `data.list` | object[] | 是 | 当前页商品数组 |
| `data.list[].productRef` | integer | 是 | 商品编号 |
| `data.list[].catalogRef` | integer | 是 | 分类编号 |
| `data.list[].title` | string | 是 | 商品名称 |
| `data.list[].price` | number | 是 | 当前接口账户购买该商品的进货价格，单位为人民币元，最多保留两位小数 |
| `data.list[].availability` | string | 是 | `available` 可以购买；`unavailable` 暂时不能购买 |
| `data.list[].inventoryType` | string | 是 | `tracked` 公开具体库存；`unlimited` 不限量；`undisclosed` 不公开库存 |
| `data.list[].remainingUnits` | integer 或 null | 是 | `tracked` 时为可售数量；其他库存类型为 `null` |
| `data.list[].subscriptionState` | string | 是 | `subscribed` 已订阅商品通知；`unsubscribed` 未订阅 |
| `data.list[].subscriptionExpiresAt` | integer 或 null | 是 | 已订阅时为到期时间；未订阅时为 `null` |
| `data.list[].revision` | integer | 是 | 商品最后更新时间，10 位 Unix 秒级时间戳 |
| `data.list[].updatedAt` | integer | 是 | 商品最后更新时间，10 位 Unix 秒级时间戳 |
| `requestId` | string | 是 | 本次请求编号，排查问题时提供给平台 |

返回示例：

```json
{
  "code": 0,
  "message": "success",
  "data": {
    "total": 1,
    "page": 1,
    "pageSize": 20,
    "list": [
      {
        "productRef": 1001,
        "catalogRef": 10,
        "title": "示例充值商品",
        "price": 100.00,
        "availability": "available",
        "inventoryType": "undisclosed",
        "remainingUnits": null,
        "subscriptionState": "subscribed",
        "subscriptionExpiresAt": 1792991000,
        "revision": 1753776000,
        "updatedAt": 1753776000
      }
    ]
  },
  "requestId": "req_01K0EXAMPLE"
}
```

列表项返回商品编号、商品名称、`price`、是否可售、库存、订阅状态、订阅到期时间和数据版本。商品介绍和全部规格通过商品详情接口获取。

### 6.3 查询商品详情

- 请求方式：`GET`
- 请求地址：`/goods/{productRef}`

请求字段：

| 字段 | 位置 | JSON 类型 | 必填 | 含义 |
| --- | --- | --- | --- | --- |
| `productRef` | Path | integer | 是 | 商品列表返回的商品编号 |

返回字段：

| 字段 | JSON 类型 | 必填 | 含义 |
| --- | --- | --- | --- |
| `code` | integer | 是 | 固定为 `0`，表示请求成功 |
| `message` | string | 是 | 本次请求的处理说明 |
| `data` | object | 是 | 商品详情 |
| `data.productRef` | integer | 是 | 商品编号 |
| `data.catalogRef` | integer | 是 | 分类编号 |
| `data.title` | string | 是 | 商品名称 |
| `data.deliveryKind` | string | 是 | `card` 表示卡密商品；`recharge` 表示充值商品 |
| `data.price` | number | 是 | 当前接口账户购买该商品的进货价格，单位为人民币元，最多保留两位小数 |
| `data.availability` | string | 是 | `available` 可以购买；`unavailable` 暂时不能购买 |
| `data.coverUrl` | string 或 null | 否 | 商品封面图片的公网地址；没有图片时为 `null` |
| `data.brand` | object 或 null | 否 | 商品品牌；商品未关联有效品牌时为 `null` |
| `data.brand.brandRef` | integer | 品牌存在时是 | 品牌编号 |
| `data.brand.name` | string | 品牌存在时是 | 品牌名称 |
| `data.brand.imageUrl` | string 或 null | 品牌存在时是 | 品牌图片的公网 HTTPS 地址；没有图片时为 `null` |
| `data.details` | string | 是 | 商品详情正文，内容为富文本 HTML；没有内容时为空字符串 |
| `data.notices` | string | 是 | 购买前注意事项，内容为富文本 HTML；没有内容时为空字符串 |
| `data.inventoryType` | string | 是 | 商品整体库存公开方式 |
| `data.remainingUnits` | integer 或 null | 是 | `tracked` 时为可售数量；其他库存类型为 `null` |
| `data.revision` | integer | 是 | 商品最后更新时间，10 位 Unix 秒级时间戳 |
| `data.subscriptionState` | string | 是 | 当前账户是否已订阅商品变化通知 |
| `data.subscriptionExpiresAt` | integer 或 null | 是 | 已订阅时为到期时间；未订阅时为 `null` |
| `data.variants` | object[] | 是 | 完整规格数组；单规格商品也会返回一项 |
| `data.variants[].variantRef` | integer | 是 | 规格编号；单规格商品固定为 `0` |
| `data.variants[].title` | string | 是 | 规格名称 |
| `data.variants[].price` | number | 是 | 当前接口账户购买该规格的进货价格，单位为人民币元，最多保留两位小数 |
| `data.variants[].availability` | string | 是 | 当前规格是否可以购买 |
| `data.variants[].inventoryType` | string | 是 | 当前规格库存公开方式 |
| `data.variants[].remainingUnits` | integer 或 null | 是 | `tracked` 时为可售数量；其他库存类型为 `null` |
| `data.variants[].purchaseSchemaRevision` | integer | 是 | 当前规格下单模板版本；值变化时重新获取该规格模板 |
| `requestId` | string | 是 | 本次请求编号，排查问题时提供给平台 |

返回示例：

```json
{
  "code": 0,
  "message": "success",
  "data": {
    "productRef": 1001,
    "catalogRef": 10,
    "title": "示例充值商品",
    "deliveryKind": "recharge",
    "price": 100.00,
    "availability": "available",
    "coverUrl": "https://cdn.example.com/goods/example.png",
    "brand": {
      "brandRef": 12,
      "name": "示例品牌",
      "imageUrl": "https://cdn.example.com/brands/example.png"
    },
    "details": "<p>这里是商品详情介绍</p>",
    "notices": "<p>下单前请确认充值账号正确</p>",
    "inventoryType": "undisclosed",
    "remainingUnits": null,
    "revision": 1753776000,
    "subscriptionState": "subscribed",
    "subscriptionExpiresAt": 1792991000,
    "variants": [
      {
        "variantRef": 0,
        "title": "默认规格",
        "price": 100.00,
        "availability": "available",
        "inventoryType": "undisclosed",
        "remainingUnits": null,
        "purchaseSchemaRevision": 1753776000
      }
    ]
  },
  "requestId": "req_01K0EXAMPLE"
}
```

返回一个商品的品牌、详情正文、注意事项、图片、全部规格和当前订阅状态。品牌只在商品详情和 `product.changed.productSnapshot` 中返回，商品列表不返回品牌。`brand` 是 v1 新增的非必填响应字段，接入模型必须允许字段缺失；当前接口在商品未关联有效品牌时返回 `null`。当前平台的“商品介绍”就是这里的 `details` 商品详情正文；“注意事项”单独通过 `notices` 返回，不能把两项内容合并处理。第三方把富文本显示给用户前必须进行 HTML 安全过滤。订阅只决定是否接收商品变化通知，不影响查询下单模板和创建订单。商品不存在或当前账户无权查看时，都会返回“商品不可见”。

- `inventoryType=tracked` 时，`remainingUnits` 是当前可售数量。
- `inventoryType=unlimited` 时表示不限量，`remainingUnits` 为 `null`。
- `inventoryType=undisclosed` 时表示不公开库存，`remainingUnits` 为 `null`，是否可以购买只看 `availability`。
- 充值商品及其规格返回 `inventoryType=undisclosed`、`remainingUnits=null`，不要根据库存数量判断是否可以下单。
- 单规格商品也会返回一项规格，其 `variantRef` 固定为数字 `0`。
- 本接口只返回每个规格的 `purchaseSchemaRevision`，完整下单模板仍通过 `GET /goods/tpl` 获取。

### 6.4 查询商品下单模板

- 请求方式：`GET`
- 请求地址：`/goods/tpl`

在第三方平台创建本地商品或新增规格时调用一次。`productRef` 必须填写；多规格商品还必须填写商品详情返回的 `variantRef`，单规格商品不填写时自动使用默认规格。接口返回：

多规格充值商品不在商品或规格记录中保存自定义模板。平台按该规格已配置购买通道的固定顺序取第一份有效下单模板和数量规则；购买通道存在多级关系时逐级使用相同规则。创建 OpenAPI 订单时固定使用与查询结果相同的购买通道，不会切换到其他模板。前台购买规则不受此约束。

请求字段：

| 字段 | 位置 | JSON 类型 | 必填 | 含义 |
| --- | --- | --- | --- | --- |
| `productRef` | Query | integer | 是 | 商品编号 |
| `variantRef` | Query | integer | 多规格是 | 规格编号；单规格商品不填时自动使用数字 `0` |

返回字段：

| 字段 | JSON 类型 | 必填 | 含义 |
| --- | --- | --- | --- |
| `code` | integer | 是 | 固定为 `0`，表示请求成功 |
| `message` | string | 是 | 本次请求的处理说明 |
| `data` | object | 是 | 指定商品规格的下单模板 |
| `data.productRef` | integer | 是 | 模板所属商品编号 |
| `data.variantRef` | integer | 是 | 模板所属规格编号 |
| `data.purchaseSchemaRevision` | integer | 是 | 下单模板版本；值变化时重新获取模板 |
| `data.unitRule` | object | 是 | 购买数量规则 |
| `data.unitRule.mode` | string | 是 | `range` 按范围和步长选择；`fixedSet` 只能选择固定数量 |
| `data.unitRule.minimum` | integer | 是 | 最少购买数量 |
| `data.unitRule.maximum` | integer | 是 | 最多购买数量 |
| `data.unitRule.increment` | integer | 是 | `range` 模式的数量步长 |
| `data.unitRule.allowedValues` | integer[] | 是 | `fixedSet` 模式允许的数量；`range` 模式为空数组 |
| `data.entryMode` | string | 是 | `singleEntry` 提交一组资料；`oneEntryPerUnit` 每件商品提交一组资料 |
| `data.purchaseFields` | object[] | 是 | 第三方下单页面需要展示的字段；没有字段时为空数组 |
| `data.purchaseFields[].fieldRef` | string | 是 | 下单字段编号；创建订单时原样作为字段名 |
| `data.purchaseFields[].control` | string | 是 | `singleLine` 单行文本；`numeric` 数字；`multiLine` 多行文本；`dropdown` 下拉；`singleChoice` 单选；`multipleChoice` 多选；`qrText` 二维码文本；`imageUrl` 公网图片地址 |
| `data.purchaseFields[].title` | string | 是 | 下单字段名称 |
| `data.purchaseFields[].mandatory` | boolean | 是 | `true` 表示创建订单时必须填写 |
| `data.purchaseFields[].placeholder` | string | 是 | 输入框提示文字；没有提示时为空字符串 |
| `data.purchaseFields[].description` | string | 是 | 字段补充说明；没有说明时为空字符串 |
| `data.purchaseFields[].constraints` | object | 是 | 输入值校验规则 |
| `data.purchaseFields[].constraints.pattern` | string | 否 | 输入值需要匹配的正则表达式 |
| `data.purchaseFields[].constraints.patternDialect` | string | 否 | 返回正则时固定为 `ECMAScript` |
| `data.purchaseFields[].constraints.serverValidationOnly` | boolean | 是 | `true` 表示第三方只做基础校验，提交后由平台完成完整校验 |
| `data.purchaseFields[].constraints.minLength` | integer | 否 | 文本最少字符数 |
| `data.purchaseFields[].constraints.maxLength` | integer | 否 | 文本最多字符数 |
| `data.purchaseFields[].constraints.min` | number | 否 | 数字最小值 |
| `data.purchaseFields[].constraints.max` | number | 否 | 数字最大值 |
| `data.purchaseFields[].choices` | object[] | 是 | 下拉、单选或多选的可选项；其他控件为空数组 |
| `data.purchaseFields[].choices[].text` | string | 是 | 展示给用户的选项名称 |
| `data.purchaseFields[].choices[].code` | string | 是 | 创建订单时提交的选项值 |
| `data.needsOtp` | boolean | 是 | `true` 表示订单处理过程中会要求提交验证码 |
| `requestId` | string | 是 | 本次请求编号，排查问题时提供给平台 |

返回示例：

```json
{
  "code": 0,
  "message": "success",
  "data": {
    "productRef": 1001,
    "variantRef": 0,
    "purchaseSchemaRevision": 1753776000,
    "unitRule": {
      "mode": "range",
      "minimum": 1,
      "maximum": 1,
      "increment": 1,
      "allowedValues": []
    },
    "entryMode": "singleEntry",
    "purchaseFields": [
      {
        "fieldRef": "account",
        "control": "singleLine",
        "title": "充值手机号",
        "mandatory": true,
        "placeholder": "请输入 11 位手机号",
        "description": "请确认号码正确，提交后无法修改",
        "constraints": {
          "serverValidationOnly": true,
          "minLength": 11,
          "maxLength": 11
        },
        "choices": []
      },
      {
        "fieldRef": "region",
        "control": "dropdown",
        "title": "号码归属省份",
        "mandatory": true,
        "placeholder": "请选择省份",
        "description": "提交选项的 code",
        "constraints": {
          "serverValidationOnly": false
        },
        "choices": [
          {
            "text": "广东",
            "code": "guangdong"
          },
          {
            "text": "浙江",
            "code": "zhejiang"
          }
        ]
      }
    ],
    "needsOtp": false
  },
  "requestId": "req_01K0EXAMPLE"
}
```

数量规则：

- 商品未配置最高购买数量时，`maximum` 默认为 `100`；商品可明确配置为 `101`～`200`，任何订单的绝对上限均为 `200`。
- `range`：数量必须在 `minimum` 和 `maximum` 之间，并且符合 `increment` 步长。例如最小值为 1、步长为 2 时，可以购买 1、3、5 件。
- `fixedSet`：数量只能使用 `allowedValues` 中列出的值。

`control` 与下单页面控件的对应关系：

| `control` | 页面控件和提交要求 |
| --- | --- |
| `singleLine` | 单行文本框，提交字符串 |
| `numeric` | 数字输入框，提交数字字符串 |
| `multiLine` | 多行文本框，提交字符串 |
| `dropdown` | 下拉选择，提交选项的 `code` |
| `singleChoice` | 单选，提交选项的 `code` |
| `multipleChoice` | 多选，提交选项 `code` 组成的字符串数组 |
| `qrText` | 已识别的二维码文本，提交识别结果字符串 |
| `imageUrl` | 公网 HTTPS 图片地址，提交 URL 字符串 |

`fieldRef` 必须原样作为 `purchaseEntries[]` 对象中的字段名。不要自行生成字段编号，也不要继续使用其他模板版本中的字段编号。

`imageUrl` 不需要上传文件，只提交公网可访问的 HTTPS 图片 URL。URL 最长 2048 个字符，可以包含查询参数，但不能提交 `data:`、Base64、本地路径、IP 地址、内网地址或回环地址；订单处理完成前必须保持图片可以访问。

第三方应保存完整返回结果并据此生成本地下单表单。正常创建订单直接使用本地保存的模板，不需要再次查询商品详情或下单模板。

`product.changed` 的 `changedProperties` 包含 `purchaseSchemaRevision`，并且 `productSnapshot` 中的版本与本地版本不同时，重新调用本接口并更新本地下单表单。

### 6.5 变更商品订阅

- 请求方式：`POST`
- 请求地址：`/goods/subscriptions`

批量订阅：

```json
{
  "action": "subscribe",
  "productRefs": [1001]
}
```

批量取消：

```json
{
  "action": "unsubscribe",
  "productRefs": [
    1001,
    1002
  ]
}
```

清空全部订阅：

```json
{
  "action": "clearAll"
}
```

`subscribe` 和 `unsubscribe` 一次最多提交 100 个 `productRef`。`clearAll` 不能同时提交 `productRefs`，它会删除当前账户的全部商品订阅。平台会逐个商品检查权限并返回哪些成功、哪些失败；`clearAll` 会返回实际删除了多少个订阅。

请求字段：

| 字段 | JSON 类型 | 必填 | 含义 |
| --- | --- | --- | --- |
| `action` | string | 是 | `subscribe` 订阅；`unsubscribe` 取消订阅；`clearAll` 清空全部订阅 |
| `productRefs` | integer[] | 订阅或取消时是 | 要处理的商品编号数组，最多 100 个；`clearAll` 时不能提交 |

返回字段：

| 字段 | JSON 类型 | 必填 | 含义 |
| --- | --- | --- | --- |
| `code` | integer | 是 | 固定为 `0`，表示请求成功 |
| `message` | string | 是 | 本次请求的处理说明 |
| `data` | object | 是 | 本次处理结果 |
| `data.acceptedCount` | integer | 是 | 处理成功的商品数量 |
| `data.rejectedCount` | integer | 是 | 处理失败的商品数量 |
| `data.items` | object[] | 是 | 每个商品的处理结果；`clearAll` 时可以为空数组 |
| `data.items[].productRef` | integer | 是 | 商品编号 |
| `data.items[].accepted` | boolean | 是 | `true` 处理成功；`false` 处理失败 |
| `data.items[].code` | string 或 null | 否 | 失败原因码；成功时为 `null` |
| `data.items[].message` | string 或 null | 否 | 失败原因说明；成功时为 `null` |
| `requestId` | string | 是 | 本次请求编号，排查问题时提供给平台 |

返回示例（对应上面的订阅商品 `1001` 请求）：

```json
{
  "code": 0,
  "message": "success",
  "data": {
    "acceptedCount": 1,
    "rejectedCount": 0,
    "items": [
      {
        "productRef": 1001,
        "accepted": true,
        "code": null,
        "message": null
      }
    ]
  },
  "requestId": "req_01K0EXAMPLE"
}
```

订阅和推送规则：

- 单个商品处理失败时，`data.items[].code` 只会返回 `INVALID_ARGUMENT`（商品编号无效或重复）或 `SUBSCRIPTION_NOT_ALLOWED`（当前账户不能订阅该商品）；同一批次中的其他合法商品仍会继续处理。
- Webhook 默认启用，不设置事件开关。账户已经保存有效的默认回调地址时，订阅成功后会推送一次商品当前完整数据；没有回调地址时只保存订阅。
- 后续只在商品价格、库存、是否可售、规格列表或下单模板版本变化时推送。
- 未订阅或已经主动取消订阅的商品不会推送。
- 每个订阅有效 90 天。再次执行 `subscribe`，或者成功创建该商品订单，都会从当天起重新计算 90 天；成功订单会在没有订阅时自动创建订阅。
- 查询目录、查询详情、查询下单模板、商品自身变化和 Webhook 投递成功都不会续期。到期后该商品立即表现为 `unsubscribed` 并停止生成新的 `product.changed`；平台不会物理删除过期记录。
- 第三方可以根据 `subscriptionExpiresAt` 在到期前再次执行 `subscribe`。自动到期不会额外发送 Webhook。
- 一条 `product.changed` 按既定三次尝试仍未收到 HTTP 200 + `OK` 时，平台会取消该商品订阅；之后需要重新执行 `subscribe` 或成功创建该商品的新订单才能恢复。订单、卡密和售后 Webhook 投递失败不会取消商品订阅。
- 商户停止供应或商品永久下架时，会最后推送一次 `availability: unavailable`。
- 同一商品短时间内连续变化时，平台可以合并成一次完整商品数据。商品推送不保证到达顺序；需要确认当前价格、库存或状态时，重新查询商品列表或详情。

## 7. 订单

### 7.1 创建订单

- 请求方式：`POST`
- 请求地址：`/orders/create`

创建订单前不需要再次查询商品详情或下单模板。卡密商品只提交商品、规格和数量；充值商品还要提交第三方在商品对接时保存的下单模板版本和充值资料。请求中不提交价格，平台按下单时该账户的最新销售价格计算，金额最多保留两位小数；余额实际扣款、订单金额和返回的 `data.amount` 完全一致。如果价格在商品对接后发生变化，本单仍按下单时的最新价格成交。

请求：

```json
{
  "clientOrderRef": "PO-20260728-0001",
  "productRef": 1001,
  "units": 1,
  "purchaseSchemaRevision": 1753776000,
  "orderCallbackUrl": "https://buyer.example.com/kys/orders/PO-20260728-0001/events",
  "purchaseEntries": [
    {
      "account": "13800138000",
      "region": "guangdong"
    }
  ]
}
```

请求字段：

| 字段 | JSON 类型 | 必填 | 含义 |
| --- | --- | --- | --- |
| `clientOrderRef` | string | 是 | 第三方订单号 |
| `productRef` | integer | 是 | 要购买的商品编号 |
| `variantRef` | integer | 多规格是 | 规格编号；单规格商品不填时自动使用数字 `0` |
| `units` | integer | 是 | 购买数量，必须符合下单模板中的 `unitRule` |
| `purchaseSchemaRevision` | integer | 充值商品是 | 本地保存的下单模板版本；卡密商品不填 |
| `orderCallbackUrl` | string | 否 | 当前订单、卡密、退款、关联售后和对应商品变化的公网 HTTPS 接收地址 |
| `purchaseEntries` | object[] | 充值商品按模板 | 充值资料数组；卡密商品不填 |
| `purchaseEntries[].{fieldRef}` | string 或 string[] | 充值商品按模板 | 字段名使用模板返回的 `fieldRef`；多选字段提交字符串数组，其他字段提交字符串 |

返回字段：

| 字段 | JSON 类型 | 必填 | 含义 |
| --- | --- | --- | --- |
| `code` | integer | 是 | 固定为 `0`，表示请求成功 |
| `message` | string | 是 | 本次请求的处理说明 |
| `data` | object | 是 | 新创建的订单 |
| `data.clientOrderRef` | string | 是 | 第三方订单号 |
| `data.platformOrderRef` | string | 是 | 平台订单号 |
| `data.productRef` | integer | 是 | 购买的商品编号 |
| `data.variantRef` | integer | 是 | 购买的规格编号 |
| `data.units` | integer | 是 | 购买数量 |
| `data.amount` | number | 是 | 实际扣款金额，单位为人民币元，最多保留两位小数 |
| `data.orderState` | string | 是 | 平台订单状态：`pending` 等待处理；`processing` 处理中；`success` 已完成；`canceled` 已取消；`partially_refunded` 部分退款；`refunded` 已退款 |
| `data.refundedAmount` | number | 是 | 累计已退款金额，单位为人民币元，最多保留两位小数 |
| `data.allowedActions` | string[] | 是 | `provideOtp` 提交验证码；`requestCancellation` 申请取消；`requestRefund` 申请退款；`openCase` 创建售后；没有可执行操作时为空数组 |
| `data.createdAt` | integer | 是 | 订单创建时间，10 位 Unix 秒级时间戳 |
| `data.updatedAt` | integer | 是 | 订单最后更新时间，10 位 Unix 秒级时间戳 |
| `data.rechargeResult` | string | 充值商品是 | 最新充值返回信息，最多 100 个字符；订单刚创建且暂无结果时为空字符串，卡密商品不返回 |
| `data.card_list` | object[] | 否 | `pending` 不返回；其他状态在卡密已经完整交付时返回 |
| `data.card_list[].card_no` | string | 是 | 卡号；没有单独卡号时为空字符串 |
| `data.card_list[].card_password` | string | 是 | 卡密或密码；没有单独密码时为空字符串 |
| `requestId` | string | 是 | 本次请求编号，排查问题时提供给平台 |

返回示例（对应第 6.4 节的充值模板和上面的下单请求，订单等待处理）：

```json
{
  "code": 0,
  "message": "success",
  "data": {
    "clientOrderRef": "PO-20260728-0001",
    "platformOrderRef": "202607280001",
    "productRef": 1001,
    "variantRef": 0,
    "units": 1,
    "amount": 100.00,
    "orderState": "pending",
    "refundedAmount": 0.00,
    "allowedActions": ["requestCancellation", "openCase"],
    "createdAt": 1785216000,
    "updatedAt": 1785216000,
    "rechargeResult": ""
  },
  "requestId": "req_01K0EXAMPLE"
}
```

充值商品的 `entryMode`：

- `purchaseEntries` 中的每个对象代表一组下单资料，字段名直接使用模板返回的 `fieldRef`，字段值填写第三方用户输入的内容。
- `purchaseFields` 为空：充值商品提交空数组。
- `singleEntry`：`purchaseFields` 不为空时，`purchaseEntries` 必须只有一个对象；当前模板未开启批量时 `units` 必须为 1。
- `oneEntryPerUnit`：每件商品提交一个对象，数组长度必须等于 `units`。

`variantRef`：

- 单规格商品可以不填写，平台自动使用默认规格。
- 多规格商品必须填写商品详情返回的规格编号。

如果模板或数量规则已经变化，平台会在扣款前返回 `PURCHASE_SCHEMA_CHANGED`。第三方重新查询 `/goods/tpl`、更新本地下单表单后，继续使用原来的 `clientOrderRef` 再次提交。

`clientOrderRef` 长度为 1 至 64 个字符，只能使用字母、数字、短横线和下划线，并且在当前采购账户内唯一：

- 网络超时或没有收到明确结果时，必须使用原 `clientOrderRef` 和原下单内容重试。平台会返回第一次创建的原订单，不会再次扣款或重复下单。
- 同一 `clientOrderRef` 不能改成另一件商品、另一规格、另一数量或不同充值资料，否则返回 `IDEMPOTENCY_CONFLICT`。
- 重试时传入不同的 `orderCallbackUrl` 不会修改原订单，仍以原订单第一次保存的地址为准。
- 请求在订单创建前因参数、模板、余额或库存校验失败时不会占用该编号；修正后仍可复用原编号。

`orderCallbackUrl` 选填：

- 用于接收该订单的状态、卡密就绪和退款事件。
- 不要求账户先保存默认回调地址，也不要求与默认地址使用相同域名。平台不会在下单前向该地址发送验证请求。
- 填写时必须使用公网 HTTPS。URL 路径和查询参数可以按订单变化。
- 订单创建成功后不能更换该订单的回调地址。
- URL 不能包含用户名或密码、`#` 片段或 IP 地址，也不能返回 HTTP 跳转。URL 可以包含查询参数。
- 提交该地址后，本订单的状态、卡密和退款消息优先发送到这里。
- 订单成功创建后，平台自动创建或续期该商品订阅，并把该地址绑定为商品通知目标；后续 `product.changed` 只向该地址推送一次，不再同时推送账户默认地址。
- 同一商品以后有新订单使用不同的 `orderCallbackUrl` 时，商品通知改发到最新地址。
- 不填写 `orderCallbackUrl` 时，不会修改这个商品当前有效订阅已经绑定的地址。商品从未绑定过订单地址时，每条商品消息生成时使用当时的默认回调地址。取消订阅或执行 `clearAll` 会立即删除商品绑定；订阅到期后旧绑定不再使用，后续重新订阅或订单续期且没有提交新地址时清除旧绑定。
- 不填写 `orderCallbackUrl` 时，平台不会在创建订单时复制默认地址。以后每条订单、卡密、退款或关联售后消息生成时，使用当时的账户默认回调地址。
- 默认回调地址修改后只影响之后新生成的消息；已经进入发送流程的消息仍使用原地址。
- 某条消息生成时既没有订单地址也没有默认地址，就不发送该条 Webhook。订单仍然正常创建，第三方通过订单详情查询状态、卡密和退款结果。

订单只有在余额扣款成功后才会创建。HTTP 2xx 表示订单已经创建，但不表示充值或发卡已经完成；最终结果看 `orderState`。余额不足时不会创建订单，也不会改用其他支付方式。

### 7.2 查询订单列表

- 请求方式：`GET`
- 请求地址：`/orders`

查询参数：

请求字段：

| 字段 | 位置 | JSON 类型 | 必填 | 含义 |
| --- | --- | --- | --- | --- |
| `page` | Query | integer | 否 | 页码，从 1 开始，默认第 1 页 |
| `pageSize` | Query | integer | 否 | 每页条数，默认 20 条，最多 100 条 |
| `clientOrderRef` | Query | string | 否 | 按第三方订单号精确查询 |
| `platformOrderRef` | Query | string | 否 | 按平台订单号精确查询 |
| `orderState` | Query | string | 否 | 按平台订单状态筛选：`pending`、`processing`、`success`、`canceled`、`partially_refunded` 或 `refunded` |
| `startedAt` | Query | integer | 否 | 创建时间范围的开始时间，10 位 Unix 秒级时间戳 |
| `endedAt` | Query | integer | 否 | 创建时间范围的结束时间，10 位 Unix 秒级时间戳 |

返回字段：

| 字段 | JSON 类型 | 必填 | 含义 |
| --- | --- | --- | --- |
| `code` | integer | 是 | 固定为 `0`，表示请求成功 |
| `message` | string | 是 | 本次请求的处理说明 |
| `data` | object | 是 | 订单分页查询结果 |
| `data.total` | integer | 是 | 符合条件的订单总数 |
| `data.page` | integer | 是 | 当前页码 |
| `data.pageSize` | integer | 是 | 当前每页条数 |
| `data.list` | object[] | 是 | 当前页订单数组 |
| `data.list[].clientOrderRef` | string | 是 | 第三方订单号 |
| `data.list[].platformOrderRef` | string | 是 | 平台订单号 |
| `data.list[].productRef` | integer | 是 | 购买的商品编号 |
| `data.list[].variantRef` | integer | 是 | 购买的规格编号 |
| `data.list[].units` | integer | 是 | 购买数量 |
| `data.list[].amount` | number | 是 | 实际扣款金额，单位为人民币元，最多保留两位小数 |
| `data.list[].orderState` | string | 是 | 平台订单状态：`pending` 等待处理；`processing` 处理中；`success` 已完成；`canceled` 已取消；`partially_refunded` 部分退款；`refunded` 已退款 |
| `requestId` | string | 是 | 本次请求编号，排查问题时提供给平台 |

返回示例：

```json
{
  "code": 0,
  "message": "success",
  "data": {
    "total": 1,
    "page": 1,
    "pageSize": 20,
    "list": [
      {
        "clientOrderRef": "PO-20260728-0001",
        "platformOrderRef": "202607280001",
        "productRef": 1001,
        "variantRef": 0,
        "units": 1,
        "amount": 100.00,
        "orderState": "pending"
      }
    ]
  },
  "requestId": "req_01K0EXAMPLE"
}
```

列表不返回卡密和充值输入。

### 7.3 查询订单详情

- 请求方式：`GET`
- 请求地址：`/orders/{clientOrderRef}`

请求字段：

| 字段 | 位置 | JSON 类型 | 必填 | 含义 |
| --- | --- | --- | --- | --- |
| `clientOrderRef` | Path | string | 是 | 创建订单时提交的第三方订单号 |

返回字段：

| 字段 | JSON 类型 | 必填 | 含义 |
| --- | --- | --- | --- |
| `code` | integer | 是 | 固定为 `0`，表示请求成功 |
| `message` | string | 是 | 本次请求的处理说明 |
| `data` | object | 是 | 订单最新详情 |
| `data.clientOrderRef` | string | 是 | 第三方订单号 |
| `data.platformOrderRef` | string | 是 | 平台订单号 |
| `data.productRef` | integer | 是 | 购买的商品编号 |
| `data.variantRef` | integer | 是 | 购买的规格编号 |
| `data.units` | integer | 是 | 购买数量 |
| `data.amount` | number | 是 | 实际扣款金额，单位为人民币元，最多保留两位小数 |
| `data.orderState` | string | 是 | 平台订单状态：`pending` 等待处理；`processing` 处理中；`success` 已完成；`canceled` 已取消；`partially_refunded` 部分退款；`refunded` 已退款 |
| `data.refundedAmount` | number | 是 | 累计已退款金额，单位为人民币元，最多保留两位小数 |
| `data.allowedActions` | string[] | 是 | `provideOtp` 提交验证码；`requestCancellation` 申请取消；`requestRefund` 申请退款；`openCase` 创建售后；没有时为空数组 |
| `data.createdAt` | integer | 是 | 订单创建时间，10 位 Unix 秒级时间戳 |
| `data.updatedAt` | integer | 是 | 订单最后更新时间，10 位 Unix 秒级时间戳 |
| `data.rechargeResult` | string | 充值商品是 | 最新充值返回信息，最多 100 个字符；暂无结果时为空字符串，卡密商品不返回 |
| `data.card_list` | object[] | 否 | `pending` 不返回；其他状态在卡密已经完整交付时返回 |
| `data.card_list[].card_no` | string | 是 | 卡号；没有单独卡号时为空字符串 |
| `data.card_list[].card_password` | string | 是 | 卡密或密码；没有单独密码时为空字符串 |
| `requestId` | string | 是 | 本次请求编号，排查问题时提供给平台 |

返回示例（充值订单已完成）：

```json
{
  "code": 0,
  "message": "success",
  "data": {
    "clientOrderRef": "PO-20260728-0001",
    "platformOrderRef": "202607280001",
    "productRef": 1001,
    "variantRef": 0,
    "units": 1,
    "amount": 100.00,
    "orderState": "success",
    "refundedAmount": 0.00,
    "allowedActions": ["requestRefund", "openCase"],
    "createdAt": 1785216000,
    "updatedAt": 1785216060,
    "rechargeResult": "充值成功"
  },
  "requestId": "req_01K0EXAMPLE"
}
```

返回示例（另一笔卡密订单已完整交付，卡号与卡密均为虚构示例）：

```json
{
  "code": 0,
  "message": "success",
  "data": {
    "clientOrderRef": "PO-20260728-0002",
    "platformOrderRef": "202607280002",
    "productRef": 2001,
    "variantRef": 0,
    "units": 1,
    "amount": 50.00,
    "orderState": "success",
    "refundedAmount": 0.00,
    "allowedActions": ["requestRefund", "openCase"],
    "createdAt": 1785216000,
    "updatedAt": 1785216060,
    "card_list": [
      {
        "card_no": "DEMO-CARD-0001",
        "card_password": "DEMO-PASSWORD-0001"
      }
    ]
  },
  "requestId": "req_01K0EXAMPLE"
}
```

返回订单最新状态和处理结果。充值商品订单通过 `rechargeResult` 返回可直接展示给用户的最新处理结果；卡密订单处于 `pending` 时不返回卡密，进入其他状态且卡密完整交付后通过 `card_list` 返回。没有收到订单回调时，可以用这个接口主动查询。不要在日志或客服系统中记录卡密明文。

### 7.4 提交验证码

- 请求方式：`POST`
- 请求地址：`/orders/{clientOrderRef}/verification-code`

仅当 `allowedActions` 包含 `provideOtp` 时可调用。

```json
{
  "otp": "123456"
}
```

请求字段：

| 字段 | 位置 | JSON 类型 | 必填 | 含义 |
| --- | --- | --- | --- | --- |
| `clientOrderRef` | Path | string | 是 | 创建订单时提交的第三方订单号 |
| `otp` | Body | string | 是 | 订单处理所需的验证码，1 至 32 个字符 |

返回字段：

| 字段 | JSON 类型 | 必填 | 含义 |
| --- | --- | --- | --- |
| `code` | integer | 是 | 固定为 `0`，表示请求成功 |
| `message` | string | 是 | 本次请求的处理说明 |
| `data` | object | 是 | 提交结果 |
| `data.submitted` | boolean | 是 | `true` 表示验证码已提交成功 |
| `requestId` | string | 是 | 本次请求编号，排查问题时提供给平台 |

返回示例：

```json
{
  "code": 0,
  "message": "success",
  "data": {
    "submitted": true
  },
  "requestId": "req_01K0EXAMPLE"
}
```

同一订单的提交频率受严格限制。失败时按 `Retry-After` 等待，不得持续轮询或高频重试。

### 7.5 申请取消订单

- 请求方式：`POST`
- 请求地址：`/orders/{clientOrderRef}/cancel`

仅当 `allowedActions` 包含 `requestCancellation` 时可调用。

```json
{
  "reason": "采购方不再需要"
}
```

请求字段：

| 字段 | 位置 | JSON 类型 | 必填 | 含义 |
| --- | --- | --- | --- | --- |
| `clientOrderRef` | Path | string | 是 | 创建订单时提交的第三方订单号 |
| `reason` | Body | string | 是 | 申请取消的原因，1 至 255 个字符 |

返回字段：

| 字段 | JSON 类型 | 必填 | 含义 |
| --- | --- | --- | --- |
| `code` | integer | 是 | 固定为 `0`，表示请求成功 |
| `message` | string | 是 | 本次请求的处理说明 |
| `data` | object | 是 | 提交结果 |
| `data.submitted` | boolean | 是 | `true` 表示取消申请已提交成功 |
| `requestId` | string | 是 | 本次请求编号，排查问题时提供给平台 |

返回示例：

```json
{
  "code": 0,
  "message": "success",
  "data": {
    "submitted": true
  },
  "requestId": "req_01K0EXAMPLE"
}
```

HTTP 2xx 只表示取消申请已提交，不会直接取消订单。最终结果以订单的 `orderState` 和订单 Webhook 为准。

### 7.6 申请退款

- 请求方式：`POST`
- 请求地址：`/orders/{clientOrderRef}/refunds`

仅当最新订单的 `allowedActions` 包含 `requestRefund` 时才能调用。该接口申请退还订单当前全部可退金额，第三方不填写退款金额。

```json
{
  "clientRefundRef": "RF-20260728-0001",
  "reason": "商品无法使用"
}
```

请求字段：

| 字段 | 位置 | JSON 类型 | 必填 | 含义 |
| --- | --- | --- | --- | --- |
| `clientOrderRef` | Path | string | 是 | 要退款的第三方订单号 |
| `clientRefundRef` | Body | string | 是 | 第三方退款编号，在当前订单内唯一；1 至 64 个字符，只能使用字母、数字、短横线和下划线；网络重试必须继续使用原编号 |
| `reason` | Body | string | 是 | 申请退款的原因，1 至 255 个字符 |

返回示例：

```json
{
  "code": 0,
  "message": "success",
  "data": {
    "clientRefundRef": "RF-20260728-0001",
    "refundRef": "RF202607290001",
    "clientOrderRef": "PO-20260728-0001",
    "refundAmount": 100.00,
    "reason": "商品无法使用",
    "createdAt": 1785216100,
    "updatedAt": 1785216100
  },
  "requestId": "req_01K0EXAMPLE"
}
```

返回字段：

| 字段 | JSON 类型 | 必填 | 含义 |
| --- | --- | --- | --- |
| `code` | integer | 是 | 固定为 `0`，表示请求成功 |
| `message` | string | 是 | 本次请求的处理说明 |
| `data` | object | 是 | 本次退款申请资料 |
| `data.clientRefundRef` | string | 是 | 第三方退款编号 |
| `data.refundRef` | string | 是 | 平台退款编号 |
| `data.clientOrderRef` | string | 是 | 退款所属的第三方订单号 |
| `data.refundAmount` | number | 是 | 本次申请退款金额，单位为人民币元，最多保留两位小数 |
| `data.reason` | string | 是 | 第三方提交的退款原因 |
| `data.createdAt` | integer | 是 | 退款申请创建时间，10 位 Unix 秒级时间戳 |
| `data.updatedAt` | integer | 是 | 退款申请最后更新时间，10 位 Unix 秒级时间戳 |
| `requestId` | string | 是 | 本次请求编号，排查问题时提供给平台 |

使用规则：

- `clientRefundRef` 由第三方生成，在当前订单内不能重复；长度为 1 至 64 个字符，只能使用字母、数字、短横线和下划线。
- 请求体不接受 `refundAmount`；平台按订单当时的全部可退金额创建待人工处理申请。
- 相同 `clientRefundRef`、订单和原因返回原退款申请，不重复发起；相同引用但内容不同返回 `IDEMPOTENCY_CONFLICT`。
- HTTP 2xx 只表示退款申请已经提交，或者返回了之前的同一申请；不会直接改变订单状态或完成退款。
- 退款申请提交后等待商户处理；接口成功不代表退款已经完成。
- 实际退款金额、累计退款金额和退款后的订单状态会通过 `order.changed` 推送。没有收到推送时，可以查询订单详情。
- 同一退款同时改变订单状态时不会再重复发送 `order.changed`。

## 8. 售后

### 8.1 创建售后

- 请求方式：`POST`
- 请求地址：`/aftersales`

请求：

```json
{
  "clientOrderRef": "PO-20260728-0001",
  "issueType": "deliveryMismatch",
  "initialMessage": "请协助核查",
  "evidenceUrls": [
    "https://buyer.example.com/evidence/1.jpg"
  ]
}
```

请求字段：

| 字段 | JSON 类型 | 必填 | 含义 |
| --- | --- | --- | --- |
| `clientOrderRef` | string | 是 | 要申请售后的第三方订单号 |
| `issueType` | string | 否 | 第三方自己的售后分类；1 至 64 个字符，只能使用字母、数字、短横线和下划线；不填时返回 `other` |
| `initialMessage` | string | 是 | 问题说明，1 至 255 个字符 |
| `evidenceUrls` | string[] | 否 | 公网 HTTPS 图片地址数组，最多 10 个，每个地址最多 255 个字符 |

返回字段：

| 字段 | JSON 类型 | 必填 | 含义 |
| --- | --- | --- | --- |
| `code` | integer | 是 | 固定为 `0`，表示请求成功 |
| `message` | string | 是 | 本次请求的处理说明 |
| `data` | object | 是 | 新创建的售后或该订单原有的售后详情 |
| `data.platformCaseRef` | string | 是 | 平台售后编号 |
| `data.clientOrderRef` | string | 是 | 售后关联的第三方订单号 |
| `data.caseState` | string | 是 | `submitted` 已提交；`handling` 处理中；`resolved` 已处理完成；`closed` 已关闭 |
| `data.issueType` | string | 是 | 第三方提交的售后分类；未提交时为 `other` |
| `data.revision` | integer | 是 | 售后最后更新时间，10 位 Unix 秒级时间戳 |
| `data.createdAt` | integer | 是 | 售后创建时间，10 位 Unix 秒级时间戳 |
| `data.updatedAt` | integer | 是 | 售后最后更新时间，10 位 Unix 秒级时间戳 |
| `data.messages` | object[] | 是 | 当前售后的全部第三方可见消息 |
| `data.messages[].messageRef` | integer | 是 | 售后消息编号 |
| `data.messages[].clientMessageRef` | string | 否 | 第三方追加消息时提交的消息编号；商户消息不返回 |
| `data.messages[].authorRole` | string | 是 | `buyer` 第三方采购方发送；`merchant` 供货商发送 |
| `data.messages[].format` | string | 是 | `text` 文字消息；`image` 公网 HTTPS 图片地址 |
| `data.messages[].body` | string | 是 | 消息正文；图片消息时是公网 HTTPS 图片地址 |
| `data.messages[].createdAt` | integer | 是 | 消息创建时间，10 位 Unix 秒级时间戳 |
| `requestId` | string | 是 | 本次请求编号，排查问题时提供给平台 |

返回示例（首次创建，包含问题说明和一张凭证图片）：

```json
{
  "code": 0,
  "message": "success",
  "data": {
    "platformCaseRef": "CP202607290001",
    "clientOrderRef": "PO-20260728-0001",
    "caseState": "submitted",
    "issueType": "deliveryMismatch",
    "revision": 1785217000,
    "createdAt": 1785217000,
    "updatedAt": 1785217000,
    "messages": [
      {
        "messageRef": 501,
        "authorRole": "buyer",
        "format": "text",
        "body": "请协助核查",
        "createdAt": 1785217000
      },
      {
        "messageRef": 502,
        "authorRole": "buyer",
        "format": "image",
        "body": "https://buyer.example.com/evidence/1.jpg",
        "createdAt": 1785217000
      }
    ]
  },
  "requestId": "req_01K0EXAMPLE"
}
```

使用规则：

- `initialMessage` 最多 255 个字符。
- `evidenceUrls` 最多 10 个，只允许 HTTPS，每个 URL 最多 255 个字符。
- `issueType` 选填，用于填写第三方自己的售后分类；长度为 1 至 64 个字符，只能使用字母、数字、短横线和下划线；不填写时按 `other` 处理。
- 订单必须属于当前账户，且 `allowedActions` 包含 `openCase`。
- 每个订单只会有一笔售后。第一次调用会创建售后并返回平台生成的 `platformCaseRef`；同一订单再次调用会返回原售后，不会重复添加首条消息或图片。后续补充内容请调用追加售后消息接口。

### 8.2 查询售后详情

- 请求方式：`GET`
- 请求地址：`/aftersales/{platformCaseRef}`

请求字段：

| 字段 | 位置 | JSON 类型 | 必填 | 含义 |
| --- | --- | --- | --- | --- |
| `platformCaseRef` | Path | string | 是 | 创建售后后由平台返回的售后编号 |

返回售后详情和消息列表：

```json
{
  "code": 0,
  "message": "success",
  "data": {
    "platformCaseRef": "CP202607290001",
    "clientOrderRef": "PO-20260728-0001",
    "caseState": "handling",
    "issueType": "deliveryMismatch",
    "revision": 1785217100,
    "createdAt": 1785217000,
    "updatedAt": 1785217100,
    "messages": [
      {
        "messageRef": 501,
        "authorRole": "buyer",
        "format": "text",
        "body": "请协助核查",
        "createdAt": 1785217000
      },
      {
        "messageRef": 502,
        "authorRole": "buyer",
        "format": "image",
        "body": "https://buyer.example.com/evidence/1.jpg",
        "createdAt": 1785217000
      },
      {
        "messageRef": 503,
        "authorRole": "merchant",
        "format": "text",
        "body": "已收到，正在核查",
        "createdAt": 1785217100
      }
    ]
  },
  "requestId": "req_01K0EXAMPLE"
}
```

返回字段：

| 字段 | JSON 类型 | 必填 | 含义 |
| --- | --- | --- | --- |
| `code` | integer | 是 | 固定为 `0`，表示请求成功 |
| `message` | string | 是 | 本次请求的处理说明 |
| `data` | object | 是 | 售后详情和消息列表 |
| `data.platformCaseRef` | string | 是 | 平台售后编号 |
| `data.clientOrderRef` | string | 是 | 售后关联的第三方订单号 |
| `data.caseState` | string | 是 | `submitted` 已提交；`handling` 处理中；`resolved` 已处理完成；`closed` 已关闭 |
| `data.issueType` | string | 是 | 第三方提交的售后分类；未提交时为 `other` |
| `data.revision` | integer | 是 | 售后最后更新时间，10 位 Unix 秒级时间戳 |
| `data.createdAt` | integer | 是 | 售后创建时间，10 位 Unix 秒级时间戳 |
| `data.updatedAt` | integer | 是 | 售后最后更新时间，10 位 Unix 秒级时间戳 |
| `data.messages` | object[] | 是 | 当前售后的全部第三方可见消息 |
| `data.messages[].messageRef` | integer | 是 | 售后消息编号 |
| `data.messages[].clientMessageRef` | string | 否 | 第三方追加消息时提交的消息编号；商户消息不返回 |
| `data.messages[].authorRole` | string | 是 | `buyer` 第三方采购方发送；`merchant` 供货商发送 |
| `data.messages[].format` | string | 是 | `text` 文字消息；`image` 公网 HTTPS 图片地址 |
| `data.messages[].body` | string | 是 | 消息正文；图片消息时是公网 HTTPS 图片地址 |
| `data.messages[].createdAt` | integer | 是 | 消息创建时间，10 位 Unix 秒级时间戳 |
| `requestId` | string | 是 | 本次请求编号，排查问题时提供给平台 |

### 8.3 追加售后消息

- 请求方式：`POST`
- 请求地址：`/aftersales/{platformCaseRef}/messages`

文本消息：

```json
{
  "clientMessageRef": "ASMSG-20260728-0001",
  "format": "text",
  "body": "补充说明"
}
```

图片消息：

```json
{
  "clientMessageRef": "ASMSG-20260728-0002",
  "format": "image",
  "body": "https://buyer.example.com/evidence/2.jpg"
}
```

请求字段：

| 字段 | 位置 | JSON 类型 | 必填 | 含义 |
| --- | --- | --- | --- | --- |
| `platformCaseRef` | Path | string | 是 | 要追加消息的平台售后编号 |
| `clientMessageRef` | Body | string | 是 | 第三方消息编号，在当前售后内唯一；1 至 64 个字符，只能使用字母、数字、短横线和下划线；网络重试必须继续使用原编号 |
| `format` | Body | string | 是 | `text` 文字消息；`image` 图片地址 |
| `body` | Body | string | 是 | 文字正文或公网 HTTPS 图片地址，最多 255 个字符 |

`clientMessageRef` 由第三方生成，在当前售后单下不能重复；长度为 1 至 64 个字符，只能使用字母、数字、短横线和下划线。网络超时后使用同一个编号重试；编号和消息内容都相同时返回原消息，不会重复追加，编号相同但内容不同时返回 `IDEMPOTENCY_CONFLICT`。文本和 URL 均不得超过 255 个字符。

成功后返回新增消息。第三方追加的消息会原样返回 `clientMessageRef`：

```json
{
  "code": 0,
  "message": "success",
  "data": {
    "messageRef": 504,
    "clientMessageRef": "ASMSG-20260728-0001",
    "authorRole": "buyer",
    "format": "text",
    "body": "补充说明",
    "createdAt": 1785217200
  },
  "requestId": "req_01K0EXAMPLE"
}
```

返回字段：

| 字段 | JSON 类型 | 必填 | 含义 |
| --- | --- | --- | --- |
| `code` | integer | 是 | 固定为 `0`，表示请求成功 |
| `message` | string | 是 | 本次请求的处理说明 |
| `data` | object | 是 | 本次新增的售后消息 |
| `data.messageRef` | integer | 是 | 售后消息编号 |
| `data.clientMessageRef` | string | 否 | 第三方提交的消息编号；第三方追加的消息会返回 |
| `data.authorRole` | string | 是 | `buyer` 第三方采购方发送；`merchant` 供货商发送 |
| `data.format` | string | 是 | `text` 文字消息；`image` 公网 HTTPS 图片地址 |
| `data.body` | string | 是 | 消息正文；图片消息时是公网 HTTPS 图片地址 |
| `data.createdAt` | integer | 是 | 消息创建时间，10 位 Unix 秒级时间戳 |
| `requestId` | string | 是 | 本次请求编号，排查问题时提供给平台 |

本接口已经同步返回第三方刚提交的消息，因此不会再通过 Webhook 把同一条消息推回。商户或平台之后新增的公开回复通过 `aftersale.message.created` 推送，并同时携带售后当前状态。

## 9. Webhook

Webhook 就是平台主动向第三方回调地址发送的 HTTP 请求。Webhook 默认启用，不设置事件开关。第三方可以在商户前台个人中心“账户管理 > 接口管理”中保存默认回调地址，也可以在创建订单时填写该订单自己的 `orderCallbackUrl`；OpenAPI 不能查询或修改默认地址。

订单填写 `orderCallbackUrl` 后，订单、卡密、退款、对应商品变化以及关联售后消息优先发送到该固定地址。订单未填写地址时，每条消息生成时读取当时的默认回调地址。同一条消息只选择一个地址并固定下来，之后修改默认地址不会改变已经生成的消息。消息生成时两个地址都没有就不发送该条 Webhook，订单仍然正常创建，第三方使用查询接口获取当前结果。

### 9.1 商品变更推送

平台使用 `POST` 发送 JSON。商品已经绑定 `orderCallbackUrl` 时发到该地址；没有绑定时，在该商品消息生成时读取当时的默认回调地址。同一条消息只发到一个固定地址，不会同时发两份。请求正文格式：

```json
{
  "eventId": "product.changed:product:1001:1753776000",
  "eventType": "product.changed",
  "occurredAt": 1785216000,
  "productRef": 1001,
  "revision": 1753776000,
  "changedProperties": ["brand", "price", "remainingUnits", "purchaseSchemaRevision"],
  "productSnapshot": {
    "productRef": 1001,
    "catalogRef": 10,
    "brand": {
      "brandRef": 12,
      "name": "示例品牌",
      "imageUrl": "https://cdn.example.com/brands/example.png"
    },
    "availability": "available",
    "revision": 1753776000,
    "variants": [
      {
        "variantRef": 0,
        "title": "默认规格",
        "price": 100.00,
        "availability": "available",
        "inventoryType": "tracked",
        "remainingUnits": 37,
        "purchaseSchemaRevision": 1753776300
      }
    ]
  }
}
```

顶层字段：

| 字段 | JSON 类型 | 必填 | 格式与含义 |
| --- | --- | --- | --- |
| `eventId` | string | 是 | 本次消息的唯一编号；第三方保存后用它判断消息是否已经处理过 |
| `eventType` | string | 是 | 固定为 `product.changed` |
| `occurredAt` | integer | 是 | 消息产生时间，10 位 Unix 秒级时间戳 |
| `productRef` | integer | 是 | 商品编号 |
| `revision` | integer | 是 | 商品最后更新时间，10 位 Unix 秒级时间戳 |
| `changedProperties` | string[] | 是 | 变化提示：`availability` 表示商品或规格的可购买状态变化；`brand` 表示商品品牌关联变化；`variants` 表示规格新增、删除或名称变化；`price` 表示规格进货价格变化；`inventoryType` 表示规格库存类型变化；`remainingUnits` 表示单规格商品或多规格商品某个规格的可售库存数量变化；`purchaseSchemaRevision` 表示规格下单模板版本变化 |
| `productSnapshot` | object | 是 | 商品变化后的完整数据 |

`productSnapshot` 字段：

| 字段 | JSON 类型 | 必填 | 格式与含义 |
| --- | --- | --- | --- |
| `productRef` | integer | 是 | 与顶层 `productRef` 相同 |
| `catalogRef` | integer | 是 | 分类编号 |
| `brand` | object 或 null | 否 | 当前商品品牌；商品未关联有效品牌时为 `null` |
| `brand.brandRef` | integer | 品牌存在时是 | 品牌编号 |
| `brand.name` | string | 品牌存在时是 | 品牌名称 |
| `brand.imageUrl` | string 或 null | 品牌存在时是 | 品牌图片的公网 HTTPS 地址；没有图片时为 `null` |
| `availability` | string | 是 | `available` 或 `unavailable` |
| `revision` | integer | 是 | 与顶层 `revision` 相同 |
| `variants` | object[] | 是 | 当前完整规格列表，至少一项；用整个数组更新第三方平台中的规格数据 |

`productSnapshot.variants[]` 字段：

| 字段 | JSON 类型 | 必填 | 格式与含义 |
| --- | --- | --- | --- |
| `variantRef` | integer | 是 | 规格编号；单规格商品为 `0` |
| `title` | string | 是 | 规格名称 |
| `price` | number | 是 | 当前接口账户购买该规格的进货价格，单位为人民币元，最多保留两位小数 |
| `availability` | string | 是 | `available` 或 `unavailable` |
| `inventoryType` | string | 是 | `tracked`、`unlimited` 或 `undisclosed` |
| `remainingUnits` | integer 或 null | 是 | `tracked` 时为大于等于 0 的可售数量；`unlimited` 或 `undisclosed` 时为 `null` |
| `purchaseSchemaRevision` | integer | 是 | 下单模板版本；值变化时重新获取模板 |

只有已经订阅且订阅没有到期的商品才会发送该消息。第三方可以直接用 `productSnapshot` 更新本地价格和库存，并检查下单模板版本是否变化。

`changedProperties` 中每个值的含义：

| 元素 | 含义 | 第三方处理方式 |
| --- | --- | --- |
| `availability` | 商品整体或至少一个规格的可购买状态发生变化 | 读取 `productSnapshot.availability` 和各规格的 `availability` |
| `brand` | 商品品牌关联发生变化 | 使用完整的 `productSnapshot.brand` 更新本地品牌；值为 `null` 时清除本地品牌关联 |
| `variants` | 规格结构发生变化，例如新增规格、删除规格或规格名称变化 | 使用完整的 `productSnapshot.variants` 更新本地规格 |
| `price` | 至少一个规格的进货价格发生变化 | 按 `variantRef` 更新各规格的 `price` |
| `inventoryType` | 至少一个规格的库存类型发生变化 | 读取对应规格的 `inventoryType`；`tracked` 公开库存，`unlimited` 不限量，`undisclosed` 不公开库存 |
| `remainingUnits` | 单规格商品的可售库存数量，或多规格商品至少一个 `tracked` 规格的可售库存数量发生变化 | 单规格商品读取 `variantRef` 为数字 `0` 的唯一规格；多规格商品按 `variantRef` 更新 `remainingUnits` |
| `purchaseSchemaRevision` | 至少一个规格的下单模板版本发生变化 | 对比各规格版本，版本与本地不同时重新调用 `GET /goods/tpl` |

商品所属分类变化不会发送 `product.changed`，`catalogRef` 不会出现在 `changedProperties` 中。`productSnapshot.catalogRef` 只是完整商品数据中的当前分类编号。一次通知可以包含多个变化提示，但不会指出具体哪个规格变化，实际更新时应按 `variantRef` 对照或直接使用完整的 `productSnapshot`。

单规格商品也会在 `productSnapshot.variants` 中返回一项，`variantRef` 固定为数字 `0`。库存类型为 `tracked` 时，该项的 `remainingUnits` 就是单规格商品当前可售库存；库存类型为 `unlimited` 或 `undisclosed` 时为 `null`。

第三方把数据成功保存后，必须返回 HTTP 200 和纯文本 `OK`；正文忽略大小写，推荐使用 `Content-Type: text/plain`。详细规则见 9.5。

### 9.2 订单状态推送

订单扣款并创建成功后的初始 `pending` 不发送。订单进入 `processing`、取消等后续状态时发送；充值结果和退款结果也使用本消息：

```json
{
  "eventId": "order.changed:status:10001:1753776100",
  "eventType": "order.changed",
  "occurredAt": 1785216001,
  "clientOrderRef": "PO-20260728-0001",
  "platformOrderRef": "D202607290001",
  "orderState": "success",
  "rechargeResult": "充值成功，到账100元"
}
```

| 字段 | JSON 类型 | 必填 | 含义 |
| --- | --- | --- | --- |
| `eventId` | string | 是 | 本次消息编号；第三方按该编号防止重复处理 |
| `eventType` | string | 是 | 固定为 `order.changed` |
| `occurredAt` | integer | 是 | 消息产生时间，10 位 Unix 秒级时间戳 |
| `clientOrderRef` | string | 是 | 第三方订单号 |
| `platformOrderRef` | string | 是 | 平台订单号 |
| `orderState` | string | 是 | 平台订单状态：`pending`、`processing`、`success`、`canceled`、`partially_refunded` 或 `refunded` |
| `rechargeResult` | string | 充值商品是 | 本次状态变化时最新的充值返回信息，最多 100 个字符；没有内容时为空字符串，卡密商品不返回 |
| `refundRef` | string | 退款时是 | 平台退款编号；与两个退款金额字段同时返回 |
| `refundAmount` | number | 退款时是 | 本次退款金额，单位为人民币元，最多保留两位小数 |
| `cumulativeRefundedAmount` | number | 退款时是 | 订单累计已退款金额，单位为人民币元，最多保留两位小数 |

充值商品每次产生新的处理结果时，`order.changed` 会同时携带当前 `orderState` 和可直接展示给用户的最新 `rechargeResult`。即使处理前后的订单状态值相同，只要本次产生了新的充值返回信息，也会发送一条新的 Webhook；充值成功发送 `order.changed(success)`。

退款完成时仍发送 `order.changed`，并额外返回本次退款编号、本次退款金额和累计退款金额：

```json
{
  "eventId": "order.changed:refund:10001:RF202607290001",
  "eventType": "order.changed",
  "occurredAt": 1785216010,
  "clientOrderRef": "PO-20260728-0001",
  "platformOrderRef": "D202607290001",
  "orderState": "partially_refunded",
  "refundRef": "RF202607290001",
  "refundAmount": 50.00,
  "cumulativeRefundedAmount": 50.00
}
```

同一次退款只发送一条 `order.changed`。再次部分退款时，即使订单状态仍为 `partially_refunded`，也会使用新的 `refundRef` 发送新消息。没有收到通知时，可以调用 `GET /orders/{clientOrderRef}` 查询最新结果。卡密订单成功只发送 `order.credentials.ready`，其中的 `orderState` 为 `success`，不会再重复发送 `order.changed(success)`。

### 9.3 卡密就绪推送

```json
{
  "eventId": "order.credentials.ready:card-delivery:10001:30001",
  "eventType": "order.credentials.ready",
  "occurredAt": 1785216002,
  "clientOrderRef": "PO-20260728-0001",
  "platformOrderRef": "D202607290001",
  "orderState": "success",
  "card_list": [
    {
      "card_no": "CARD-NO-EXAMPLE",
      "card_password": "CARD-PASSWORD-EXAMPLE"
    }
  ]
}
```

| 字段 | JSON 类型 | 必填 | 含义 |
| --- | --- | --- | --- |
| `eventId` | string | 是 | 本次消息编号；第三方按该编号防止重复保存卡密 |
| `eventType` | string | 是 | 固定为 `order.credentials.ready` |
| `occurredAt` | integer | 是 | 卡密准备完成时间，10 位 Unix 秒级时间戳 |
| `clientOrderRef` | string | 是 | 第三方订单号 |
| `platformOrderRef` | string | 是 | 平台订单号 |
| `orderState` | string | 是 | 卡密就绪后的平台订单状态，正常为 `success` |
| `card_list` | object[] | 是 | 本单已经准备完成的全部卡密 |
| `card_list[].card_no` | string | 是 | 卡号；没有单独卡号时为空字符串 |
| `card_list[].card_password` | string | 是 | 卡密或密码；没有单独密码时为空字符串 |

`card_no` 和 `card_password` 始终分开提供。没有单独卡号或密码时，对应字段返回空字符串。

- 全部卡密准备完成并保存成功后，平台才发送 `order.credentials.ready`。
- 卡密消息会优先立即发送。
- 这条消息同时表示卡密订单已经交易成功，不会再发送一条 `order.changed(success)`。
- 取消和充值订单不会发送该消息，普通状态变化由 `order.changed` 发送。
- 没有收到 `OK` 时，平台会使用同一个 `eventId` 重试。第三方必须先保存卡密和 `eventId`，再返回 `OK`，避免重复发卡。
- 三次发送都失败后，第三方可以通过 `GET /orders/{clientOrderRef}` 查询卡密。

### 9.4 售后消息推送

商户或平台新增一条第三方可以查看的售后消息时发送 `aftersale.message.created`。该消息同时携带售后当前状态和售后类型，不再另外发送售后状态通知。第三方通过追加售后消息接口提交的内容不会再回推给第三方。售后关联订单填写过 `orderCallbackUrl` 时发送到该订单地址；未填写时，在本条消息生成时读取当时的账户默认回调地址。售后接口不接收单独的回调地址。

```json
{
  "eventId": "aftersale.message.created:aftersale-message:20001:501",
  "eventType": "aftersale.message.created",
  "occurredAt": 1785216021,
  "platformCaseRef": "CP202607290001",
  "clientOrderRef": "PO-20260728-0001",
  "caseState": "handling",
  "issueType": "not_received",
  "revision": 1753775900,
  "message": {
    "messageRef": 501,
    "authorRole": "merchant",
    "format": "text",
    "body": "已收到，正在核查。",
    "createdAt": 1785216021
  }
}
```

`aftersale.message.created` 顶层字段：

| 字段 | JSON 类型 | 必填 | 含义 |
| --- | --- | --- | --- |
| `eventId` | string | 是 | 本次消息编号；第三方按该编号防止重复追加消息 |
| `eventType` | string | 是 | 固定为 `aftersale.message.created` |
| `occurredAt` | integer | 是 | 售后消息产生时间，10 位 Unix 秒级时间戳 |
| `platformCaseRef` | string | 是 | 平台售后编号 |
| `clientOrderRef` | string | 是 | 售后关联的第三方订单号 |
| `caseState` | string | 是 | 售后当前状态：`submitted` 已提交；`handling` 处理中；`resolved` 已处理完成；`closed` 已关闭 |
| `issueType` | string | 是 | 当前售后类型 |
| `revision` | integer | 是 | 售后最后更新时间，10 位 Unix 秒级时间戳 |
| `message` | object | 是 | 本次新增的售后消息 |

`message` 字段：

| 字段 | JSON 类型 | 必填 | 含义 |
| --- | --- | --- | --- |
| `messageRef` | integer | 是 | 售后消息编号 |
| `clientMessageRef` | string | 否 | 本事件不返回该字段 |
| `authorRole` | string | 是 | 固定为 `merchant`，表示商户或平台回复 |
| `format` | string | 是 | `text` 文字消息；`image` 公网 HTTPS 图片地址 |
| `body` | string | 是 | 消息正文；图片消息时是公网 HTTPS 图片地址 |
| `createdAt` | integer | 是 | 消息创建时间，10 位 Unix 秒级时间戳 |

售后消息只包含第三方可以查看的正文和 HTTPS 附件地址。三次发送都失败后，可以查询售后详情。

### 9.5 确认与重试

Webhook 没有额外签名。第三方收到消息后按以下步骤处理：

1. 检查 JSON 格式和必填字段。
2. 保存 `eventId`。同一个 `eventId` 再次收到时，不要重复发卡、记录退款或追加售后消息。
3. 卡密消息必须先安全保存，再交给后续发货流程。商品消息用于同步变化；需要确认商品当前状态时调用商品查询接口。
4. 数据保存成功后返回 HTTP 200，响应正文使用纯文本 `OK`；`OK`、`ok` 等大小写形式都可以。

正确响应示例：

```http
HTTP/1.1 200 OK
Content-Type: text/plain; charset=utf-8

OK
```

只有 HTTP 状态为 `200`，并且响应正文去除首尾空白后忽略大小写等于 `OK`，才算成功。接收方必须在 3 秒内保存消息并返回 `OK`，耗时业务应在返回后异步处理。其他状态、空正文、JSON、其他文本、超时或连接失败都视为失败；发送方不跟随 3xx。

每条消息最多发送 3 次：第一次立即发送；没有收到 `OK` 时，在第一次发送后的第 15 秒和第 30 秒各重试一次。三次使用同一个 `eventId`、回调地址和请求正文。第三次仍失败后停止发送，也不提供发送记录查询；如果失败事件是 `product.changed`，平台同时取消生成该任务的商品订阅，已经续期或重新订阅的新状态不会被旧失败任务取消。订单、卡密和售后 Webhook 失败不改变商品订阅。订单和退款可以通过 `GET /orders/{clientOrderRef}` 查询，商品可以重新查询商品列表或详情，售后可以查询售后详情。第三方必须用 `eventId` 防止重复处理。

由于 Webhook 没有签名，第三方不能只凭一条回调确认发送者身份。回调地址必须使用 HTTPS；涉及退款入账等不能撤销的资金操作时，请再调用需要身份验证的查询接口核对。

## 10. 错误处理和重试

错误示例：

```json
{
  "code": 409101,
  "message": "下单模板已更新，请重新查询商品下单模板",
  "data": null,
  "requestId": "req_01K0EXAMPLE"
}
```

### 10.1 重试原则

- 收到 `429`、部分 `5xx` 或网络超时时可以重试，但每次重试前应延长等待时间。
- `POST /orders/create` 必须复用原 `clientOrderRef` 和原下单内容，不得生成新号；同内容重试返回原订单，不会重复扣款。
- `POST /orders/{clientOrderRef}/refunds` 必须复用原 `clientRefundRef`，不得为同一退款意图生成新号。
- 参数错误、身份验证失败或没有权限时，必须先修正问题再重试。
- 收到 `Retry-After` 时必须遵守。
- 不要每秒查询。优先等待 Webhook；没有收到消息或需要核对时，再按订单号、商品号或售后号低频查询。

### 10.2 请求频率限制

每个采购账户执行以下限制：

| 接口 | 调用额度 |
| --- | --- |
| 查询账户、查询分类 | 60 次/分钟 |
| 查询商品列表、商品详情、商品下单模板 | 120 次/分钟 |
| 修改商品订阅 | 30 次/分钟；每次最多处理 100 个商品 |
| 创建订单 | 120 次/分钟；瞬时最多 10 次 |
| 查询订单列表、订单详情 | 120 次/分钟 |
| 提交验证码 | 10 次/分钟；同一订单 30 秒最多 1 次 |
| 申请取消订单、申请退款 | 30 次/分钟；同一订单 10 秒最多 1 次 |
| 创建售后、追加售后消息 | 30 次/分钟 |
| 查询售后详情 | 120 次/分钟 |

一个采购账户的全部接口合计不能超过 600 次/分钟，瞬时最多 30 次。主动发送给第三方的 Webhook 不占这些调用次数。

请求过于频繁时返回 HTTP `429`。响应中的 `Retry-After` 表示至少需要等待多少秒，同时还会返回：

- `X-RateLimit-Limit`
- `X-RateLimit-Remaining`
- `X-RateLimit-Reset`

同一请求同时检查总额度、接口额度和单个订单冷却时间，响应以当前最严格的一项为准。收到 `Retry-After` 后必须等待，不能立即循环重试。

限流服务暂时不可用时返回 HTTP `503` 和 `Retry-After: 1`。创建订单重试必须继续使用原 `clientOrderRef`，退款重试必须继续使用原 `clientRefundRef`。

完整错误码见第 15 章“完整错误码”。

## 11. 敏感信息处理

第三方不得记录：

- 完整 API Key。
- 包含 `X-Kys-Key` 的请求头和调试信息。
- 卡密明文不得写入普通日志；业务必须保存时应加密存储并限制访问。
- 充值账号、验证码和图片证据的完整内容。

接口响应只包含本文列出的第三方字段。

## 12. 接口升级时的处理

- `/openapi/v1` 会增加新的可选字段，第三方程序不要因为出现未知字段而报错。
- 删除字段、改变字段类型或改变原有含义时，会发布新的主版本。
- 收到程序不认识的状态值时，不要直接崩溃或当成成功；请保留原值并按未知状态处理。
- 新增可选字段会更新 `OPENAPI_CONTRACT_REVISION`；删除字段、改变字段类型或改变原有含义时会发布新的主版本。

## 13. 身份验证完整示例

### 13.1 接入需要的三个参数

第三方对接只需要保存以下三个参数：

| 参数 | 示例 | 说明 |
| --- | --- | --- |
| 商户域名 | `merchant.example.com` | 商户提供的 OpenAPI HTTPS 域名 |
| 前台用户 ID | `12345` | 采购会员在该商户前台的用户 ID |
| API Key | `AK_0123456789abcdef0123456789abcdef` | 该用户在“账户管理 > 接口管理”中启用的 API Key |

商户域名决定访问哪个商户，前台用户 ID 决定访问哪个采购账户，API Key 用于确认调用身份。三项必须属于同一个商户账户。

不需要 API Secret、时间戳、nonce 或请求签名。

### 13.2 每次请求如何携带

商户域名放在请求地址中，前台用户 ID 和 API Key 放在请求头中：

```http
GET /openapi/v1/account HTTP/1.1
Host: merchant.example.com
Accept: application/json
X-Kys-User-Id: 12345
X-Kys-Key: AK_0123456789abcdef0123456789abcdef
```

请求头字段：

| 请求头 | 类型 | 必填 | 说明 |
| --- | --- | --- | --- |
| `X-Kys-User-Id` | string | 是 | 采购会员在当前商户前台的用户 ID，按商户提供的原值传递 |
| `X-Kys-Key` | string | 是 | 与该前台用户 ID 绑定的 API Key |

用户 ID 和 API Key 不得放在 URL 查询参数或 JSON 请求体中。

### 13.3 cURL 示例

查询账户：

```bash
curl 'https://merchant.example.com/openapi/v1/account' \
  -H 'Accept: application/json' \
  -H 'X-Kys-User-Id: 12345' \
  -H 'X-Kys-Key: AK_0123456789abcdef0123456789abcdef'
```

创建订单：

```bash
curl 'https://merchant.example.com/openapi/v1/orders/create' \
  -X POST \
  -H 'Content-Type: application/json' \
  -H 'X-Kys-User-Id: 12345' \
  -H 'X-Kys-Key: AK_0123456789abcdef0123456789abcdef' \
  --data-raw '{"clientOrderRef":"PO-20260729-0001","productRef":1001,"units":1}'
```

### 13.4 PHP 示例

```php
<?php

$baseUrl = 'https://merchant.example.com/openapi/v1';
$userId = '12345';
$apiKey = 'AK_0123456789abcdef0123456789abcdef';

$curl = curl_init($baseUrl . '/account');
curl_setopt_array($curl, [
    CURLOPT_RETURNTRANSFER => true,
    CURLOPT_HTTPHEADER => [
        'Accept: application/json',
        'X-Kys-User-Id: ' . $userId,
        'X-Kys-Key: ' . $apiKey,
    ],
]);

$response = curl_exec($curl);
if ($response === false) {
    throw new RuntimeException('请求失败');
}

$httpStatus = curl_getinfo($curl, CURLINFO_RESPONSE_CODE);
curl_close($curl);

if ($httpStatus !== 200) {
    throw new RuntimeException('接口返回 HTTP ' . $httpStatus);
}

$data = json_decode($response, true, flags: JSON_THROW_ON_ERROR);
```

### 13.5 Node.js 示例

```js
const baseUrl = 'https://merchant.example.com/openapi/v1';
const userId = '12345';
const apiKey = 'AK_0123456789abcdef0123456789abcdef';

const response = await fetch(`${baseUrl}/account`, {
  headers: {
    Accept: 'application/json',
    'X-Kys-User-Id': userId,
    'X-Kys-Key': apiKey,
  },
});

const result = await response.json();
if (!response.ok) {
  throw new Error(`接口返回 HTTP ${response.status}，requestId=${result.requestId ?? ''}`);
}
```

### 13.6 验证失败

请求已经通过当前商户的 OpenAPI 主站域名进入接口后，以下情况统一返回 HTTP 401 和错误码 `INVALID_CREDENTIAL`：

- 没有提交前台用户 ID 或 API Key。
- 前台用户 ID 不属于当前商户。
- API Key 不属于该前台用户 ID。
- API Key 已经重置或撤销。

响应不会说明具体是哪一个身份参数错误。请同时核对前台用户 ID 和 API Key。

使用分站域名或其他未启用 OpenAPI 的域名访问 `/openapi` 时，请求不会进入 OpenAPI 身份验证，直接返回 HTTP 404。此时应改用商户提供的 OpenAPI 主站域名。

身份验证通过但会员账户已经暂停时，`GET /account` 仍会成功并返回 `accessState: "suspended"`；其他接口返回 HTTP 403 和 `CREDENTIAL_SUSPENDED`。

### 13.7 安全要求

- 所有接口必须使用 HTTPS。
- API Key 只能保存在第三方平台的后台配置中，不能放进浏览器、前端代码、URL、日志或错误信息。
- API Key 重置后旧 Key 立即失效，第三方需要及时更新配置。
- 商户设置了 IP 白名单时，请求来源还必须在白名单中。
- 平台主动发送的 Webhook 不携带上述身份验证请求头。Webhook 接收成功返回 HTTP 200 和纯文本 `OK`，正文大小写均可。

## 14. Webhook 接收代码示例

四类 Webhook 的触发条件、完整字段表、请求示例和重试规则统一以第 9 章对应事件章节为准。本章只提供接收端代码示例，不重复定义事件字段。

### 14.1 PHP 接收示例

```php
<?php

function finishWebhook(int $statusCode, string $body = ''): never
{
    http_response_code($statusCode);
    header('Content-Type: text/plain; charset=utf-8');
    echo $body;
    exit;
}

$rawBody = file_get_contents('php://input');
if (!is_string($rawBody)) {
    finishWebhook(400);
}

try {
    $event = json_decode($rawBody, true, 512, JSON_THROW_ON_ERROR);
} catch (JsonException) {
    finishWebhook(400);
}

if (!is_array($event) || !is_string($event['eventId'] ?? null)) {
    finishWebhook(400);
}

// 使用存储系统的唯一约束保存 eventId；重复事件也直接返回 OK。
// 保存成功后异步处理业务，HTTP 请求内不调用后续业务接口。

finishWebhook(200, 'OK');
```

接收方必须补充：

- `eventId` 唯一约束。
- 请求体大小限制。
- JSON schema 校验。
- 请求超时和并发限制。

### 14.2 Node.js 接收示例

```js
import express from 'express';

const app = express();
app.use(express.json({ limit: '256kb' }));

app.post('/kys/webhook', async (req, res) => {
  const event = req.body;
  if (!event || typeof event.eventId !== 'string') {
    return res.sendStatus(400);
  }

  // 以唯一约束保存 eventId，保存后异步处理业务。
  // 重复 eventId 也返回相同的 OK，避免平台继续重试。
  return res.status(200).type('text/plain').send('OK');
});
```

## 15. 完整错误码

### 15.1 状态

错误码以当前 `OPENAPI_CONTRACT_REVISION` 为准。

统一错误响应：

```json
{
  "code": 409101,
  "message": "下单模板已更新，请重新查询商品下单模板",
  "data": null,
  "requestId": "req_01K0EXAMPLE"
}
```

规则：

- HTTP 状态表达协议结果，`code` 表达稳定业务原因。
- `message` 只包含接入方可处理的业务说明。
- 接入方判断 `code`，不要依赖中文文案。
- 联系商户排查时提供 `requestId`，不得提供完整 API Key。

### 15.2 通用错误

| HTTP | code | 标识 | 含义 | 接入方处理 |
| --- | ---: | --- | --- | --- |
| 400 | 400001 | `INVALID_ARGUMENT` | 请求参数不符合契约 | 修正参数后重试 |
| 400 | 400002 | `INVALID_PUBLIC_REFERENCE` | 公开资源引用无效、类型不符或已失效 | 重新拉取资源，禁止自行解析引用 |
| 401 | 401001 | `INVALID_CREDENTIAL` | 前台用户 ID 或 API Key 缺失、无效或不匹配 | 核对当前商户下的前台用户 ID 和 API Key 后重试 |
| 403 | 403005 | `CREDENTIAL_SUSPENDED` | 会员账户已暂停；只有 `GET /account` 仍可返回 `accessState=suspended` | 联系商户管理员恢复账户 |
| 403 | 403002 | `SOURCE_IP_DENIED` | 来源 IP 不在白名单 | 核对出口 IP 和商户配置 |
| 404 | 404001 | `RESOURCE_NOT_VISIBLE` | 资源不存在或对当前账户不可见 | 重新同步资源 |
| 404 | 404002 | `ORDER_NOT_FOUND` | 当前账户下不存在对应的 OpenAPI 订单 | 核对第三方订单号或平台订单号 |
| 404 | 404003 | `AFTERSALE_NOT_FOUND` | 当前账户下不存在对应的售后 | 核对平台售后编号 |
| 409 | 409001 | `IDEMPOTENCY_CONFLICT` | 同一客户端引用再次提交了不同业务内容 | 使用原内容重试；确实是新业务时生成新引用 |
| 429 | 429001 | `RATE_LIMITED` | 请求频率超限 | 遵守 `Retry-After` 并逐步延长等待时间 |
| 500 | 500001 | `INTERNAL_ERROR` | 系统处理失败 | 保存 `requestId`，延迟重试 |
| 503 | 503001 | `TEMPORARILY_UNAVAILABLE` | 服务暂时不可用 | 逐步延长等待时间；订单请求复用原 `clientOrderRef` |

### 15.3 商品和订阅

| HTTP | code | 标识 | 含义 | 接入方处理 |
| --- | ---: | --- | --- | --- |
| 404 | 404001 | `RESOURCE_NOT_VISIBLE` | 商品、规格或分类不存在，或者对当前账户不可见 | 重新查询分类、商品目录或商品详情 |

### 15.4 购买模板和订单

| HTTP | code | 标识 | 含义 | 接入方处理 |
| --- | ---: | --- | --- | --- |
| 409 | 409101 | `PURCHASE_SCHEMA_CHANGED` | 下单模板已经变化或当前暂不可用 | 重新查询 `/goods/tpl` 并按最新模板提交 |
| 409 | 409102 | `CANCELLATION_NOT_ALLOWED` | 当前订单不能申请取消 | 查询订单详情并读取最新 `allowedActions` |
| 409 | 409103 | `REFUND_NOT_ALLOWED` | 当前订单没有可退金额、不能申请退款或已有待处理退款申请 | 查询订单详情并等待当前退款申请处理完成 |
| 422 | 422100 | `ORDER_CREATE_REJECTED` | 订单暂时无法创建，且没有更具体的业务原因 | 根据 `message` 核对商品和订单资料 |
| 422 | 422101 | `PRODUCT_UNAVAILABLE` | 商品类型、商品或规格当前不可购买 | 重新查询商品详情 |
| 422 | 422102 | `INVENTORY_UNAVAILABLE` | 商品当前暂时无法供应 | 稍后查询商品详情后重试 |
| 422 | 422103 | `BALANCE_INSUFFICIENT` | 可用余额不足 | 充值后使用原 `clientOrderRef` 查询或按返回语义重试 |
| 422 | 422110 | `UNITS_NOT_ALLOWED` | 购买数量不符合当前商品规则 | 按 `unitRule` 修正 |
| 422 | 422111 | `PURCHASE_INPUT_INVALID` | `purchaseEntries` 的数量、分组或字段编号不符合当前下单模板 | 按 `entryMode` 和 `purchaseFields` 修正，每组直接使用 `fieldRef` 作为字段名 |
| 422 | 422112 | `IMAGE_URL_INVALID` | `imageUrl` 不是长度不超过 2048 的公网 HTTPS 图片 URL | 提交持续可访问的公网 HTTPS 地址；不要提交文件、Base64、本地或内网地址 |
| 422 | 422114 | `ORDER_CALLBACK_URL_INVALID` | 已提交的订单回调地址不是允许的公网 HTTPS URL | 修正地址；也可以删除 `orderCallbackUrl` 后正常下单并使用订单查询兜底 |
| 422 | 422120 | `OTP_NOT_ALLOWED` | 当前订单不能提交验证码 | 查询订单详情并读取最新 `allowedActions` |
| 429 | 429110 | `OTP_ATTEMPTS_LIMITED` | 验证码提交过于频繁 | 遵守 `Retry-After` |
| 429 | 429111 | `CANCELLATION_ATTEMPTS_LIMITED` | 取消申请提交过于频繁 | 遵守 `Retry-After` |
| 429 | 429112 | `REFUND_ATTEMPTS_LIMITED` | 退款申请提交过于频繁 | 遵守 `Retry-After` |

充值商品才会返回购买模板相关错误；卡密商品不要求提交 `purchaseSchemaRevision` 或 `purchaseEntries`。

创建订单时，`clientOrderRef` 在当前采购账户内唯一。相同编号和相同商品、规格、数量及充值资料会返回原订单；更换其中任一内容会返回 `IDEMPOTENCY_CONFLICT`。`orderCallbackUrl` 不用于判断内容是否相同，重试不能修改原订单第一次保存的回调地址。

### 15.5 售后

| HTTP | code | 标识 | 含义 | 接入方处理 |
| --- | ---: | --- | --- | --- |
| 409 | 409104 | `CASE_ACTION_NOT_ALLOWED` | 当前订单不能创建售后，或者当前售后不能再追加消息 | 刷新订单或售后详情 |
| 400 | 400001 | `INVALID_ARGUMENT` | 售后分类、说明、消息、图片数量或长度不符合要求 | 按接口字段说明修正请求 |
| 422 | 422112 | `IMAGE_URL_INVALID` | 售后图片地址不是允许的公网 HTTPS URL | 提交持续可访问的公网 HTTPS 图片地址 |

### 15.6 重试矩阵

| 场景 | 是否可自动重试 | 约束 |
| --- | --- | --- |
| 网络超时、429、503 | 可以 | 逐步延长等待时间；订单复用原 `clientOrderRef`，退款复用原 `clientRefundRef`，售后消息复用原 `clientMessageRef` |
| 500 | 有限次数 | 保存 `requestId`，设置最大次数和熔断 |
| 400、401、403、404、409、422 | 默认不可以 | 先修正请求或刷新资源 |
