# Open API v1 对接文档（中文）

## 1. 概览

| 项目 | 说明 |
| --- | --- |
| 基础地址 | `https://{your-domain}/api/open/v1` |
| 数据格式 | JSON 使用 UTF-8；文件接口使用 `multipart/form-data`。 |
| 认证方式 | 每个请求均使用 Client ID、时间戳、Nonce 和 HMAC-SHA256 签名。 |
| 价格与支付 | 服务端按绑定结算账户重新计算价格、库存和余额；请求中不传价格。可在 Client 中开启代理价格。 |
| 运单 | 第三方先上传外部运单；接口不会在线创建或购买运单。 |
| 下发工厂 | 创建成功后立即投递工厂下发队列。 |

超级管理员在后台的“开放接口管理”创建 Client。每个 Client 固定绑定一个内部公司和一个已启用的结算账户，可配置权限、可获取的主产品、代理价格开关、IP 白名单、Webhook 和启停状态。新 Client 默认没有任何可获取商品；未勾选商品时商品接口返回空列表，且不能创建该商品的订单。

`Client Secret` 当前按业务要求明文保存且可在后台查看；请仅授予可信管理员权限，并通过安全渠道交付给第三方。

## 2. 认证与签名

### 2.1 请求头

| Header | 必填 | 说明 |
| --- | --- | --- |
| `X-Open-Client-Id` | 是 | 后台创建的 Client ID。 |
| `X-Open-Timestamp` | 是 | Unix 秒级时间戳；与服务器时间误差不超过 5 分钟。 |
| `X-Open-Nonce` | 是 | 每次请求唯一的随机值，推荐 UUID；同一 Client 6 分钟内不可重复。 |
| `X-Open-Signature` | 是 | 小写十六进制 HMAC-SHA256 签名。 |
| `X-Open-Content-SHA256` | 文件上传时 | 上传文件二进制内容的 SHA-256 小写十六进制摘要。 |
| `X-Idempotency-Key` | 创建订单时建议必填 | 幂等键，最长 128 字符。 |

### 2.2 签名规则

使用 Client Secret 对以下 UTF-8 文本计算 HMAC-SHA256：

```text
TIMESTAMP\nNONCE\nMETHOD\nPATH\nBODY_SHA256
```

| 字段 | 说明 |
| --- | --- |
| `TIMESTAMP` | `X-Open-Timestamp` 原样值。 |
| `NONCE` | `X-Open-Nonce` 原样值。 |
| `METHOD` | 大写 HTTP 方法，如 `GET`、`POST`。 |
| `PATH` | 不含域名和查询参数，例如 `/api/open/v1/orders`。 |
| `BODY_SHA256` | JSON 请求为实际原始请求体的 SHA-256；GET 等空请求体为空字符串 SHA-256。 |

空字符串 SHA-256：

```text
e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855
```

JSON 签名时必须把签名使用的原始字节作为实际 HTTP 请求体发送；签名后不能重新格式化 JSON。

```python
import hashlib, hmac, json, time, uuid

path = '/api/open/v1/orders'
payload = {'external_order_no': 'partner-10001', 'shipments': []}
body = json.dumps(payload, ensure_ascii=False, separators=(',', ':')).encode('utf-8')
timestamp = str(int(time.time()))
nonce = str(uuid.uuid4())
body_hash = hashlib.sha256(body).hexdigest()
to_sign = '\n'.join([timestamp, nonce, 'POST', path, body_hash])
signature = hmac.new(client_secret.encode(), to_sign.encode(), hashlib.sha256).hexdigest()
```

### 2.3 文件上传签名

不要对完整的 `multipart/form-data` 请求体签名，因为 boundary 由浏览器或 HTTP 库生成。应当：

1. 计算文件二进制内容 SHA-256；
2. 将摘要填入 `X-Open-Content-SHA256`；
3. 将相同摘要作为签名字符串的 `BODY_SHA256`；
4. 以唯一表单字段 `file` 上传文件。

不要手动指定 multipart 的 `Content-Type` boundary。

## 3. 接口清单

| 方法 | 路径 | 权限 | 用途 |
| --- | --- | --- | --- |
| `GET` | `/products` | `catalog` | 查询商品主产品及规格。 |
| `GET` | `/products/{main_product_id}` | `catalog` | 查询单个主产品及全部可售规格。 |
| `POST` | `/uploads/images` | `uploads` | 上传印刷图片。 |
| `POST` | `/uploads/shipping-labels` | `uploads` | 上传第三方外部运单。 |
| `POST` | `/orders` | `orders` | 创建外部订单并排队下发工厂。 |
| `GET` | `/orders` | `orders` | 分页查询当前 Client 的订单。 |
| `GET` | `/orders/{external_order_no}` | `orders` | 查询指定外部订单。 |
| `POST` | `/orders/{external_order_no}/cancel` | `orders` | 取消 10 分钟内创建的订单。 |
| `GET` | `/balance` | `account` | 查询绑定结算账户的余额。 |
| `GET` | `/inventory/{main_product_id}` | `catalog` | 查询指定主产品下各规格的系统最新库存。 |

后台可设置 `*`、`catalog`、`uploads`、`orders`、`account` 权限；`*` 表示全部权限。

## 4. 商品接口

### 4.1 查询商品列表

```http
GET /api/open/v1/products?per_page=20
```

`per_page` 可选，范围 `1–100`，默认 `20`。响应的 `data` 为 Laravel 分页对象，商品数组位于 `data.data`。

```json
{
  "code": "OK",
  "data": {
    "current_page": 1,
    "data": [{
      "id": 2,
      "name": "Canvas Wrap",
      "description": "",
      "image_url": "/storage/images/products/main_product_2.jpg",
      "upload_num": 1,
      "updated_at": "2026-07-16T08:00:00+00:00",
      "variants": [{
        "id": 29,
        "name": "11\" x 14\"",
        "model": "CW-11X14",
        "type": "fixed_size",
        "width": 11,
        "height": 14,
        "price": 12.5,
        "currency": "USD",
        "quantity": 100,
        "image_url": null,
        "updated_at": "2026-07-16T08:00:00+00:00"
      }]
    }],
    "per_page": 20,
    "total": 1
  }
}
```

### 4.2 查询商品详情

```http
GET /api/open/v1/products/2
```

响应商品字段与列表单项一致。不存在或已禁用时返回：

```json
{"code":"PRODUCT_NOT_FOUND","message":"Product not found."}
```

### 4.3 商品字段与下单规则

| 字段 | 说明 |
| --- | --- |
| `id` | 主产品 ID；详情接口使用。 |
| `variants[].id` | 创建订单时的 `product_id`。 |
| `type` | `fixed_size` 或 `custom_size`。 |
| `width`、`height` | 固定尺寸商品必须按目录配置；自定义尺寸商品下单时必须传正数。 |
| `price` | 当前结算账户的 USD 单价，仅供展示/预估；实际金额由服务端计算。 |
| `price_tiers` | 仅开启代理价格时返回。来自“折扣管理”，每项含 `min_quantity` 和 `price`；未开启时为空数组。 |
| `quantity` | 该规格的当前库存数量；下单时服务端会校验并扣减。 |
| `upload_num` | 每件商品需要的印刷图片数量。 |

### 4.4 查询系统最新库存

```http
GET /api/open/v1/inventory/2
```

返回指定主产品下各规格的**系统最新库存数量**（`products.quantity`）。主产品不存在、已禁用或未授权给当前 Client 时返回：

```json
{"code":"PRODUCT_NOT_FOUND","message":"Product not found."}
```

```json
{
  "code": "OK",
  "data": {
    "main_product_id": 2,
    "main_product_name": "Canvas Wrap",
    "variants": [{
      "product_id": 29,
      "name": "11\" x 14\"",
      "model": "CW-11X14",
      "type": "fixed_size",
      "width": 11,
      "height": 14,
      "quantity": 100,
      "updated_at": "2026-07-16T08:00:00+00:00"
    }]
  }
}
```

| 字段 | 说明 |
| --- | --- |
| `data.main_product_id` | 主产品 ID。 |
| `data.main_product_name` | 主产品名称。 |
| `data.variants` | 该主产品下可售规格的库存列表。 |
| `variants[].product_id` | 规格 ID，与商品接口 `variants[].id` 一致。 |
| `variants[].quantity` | 该规格的系统最新库存数量（`products.quantity`）。 |

## 5. 上传接口

### 5.1 上传印刷图片

```http
POST /api/open/v1/uploads/images
Content-Type: multipart/form-data
```

表单字段为 `file`；允许 `JPG`、`JPEG`、`PNG`；最大 200 MB。

### 5.2 上传外部运单

```http
POST /api/open/v1/uploads/shipping-labels
Content-Type: multipart/form-data
```

表单字段为 `file`；允许 `JPG`、`JPEG`、`PNG`、`PDF`；最大 200 MB。

成功响应：

```json
{
  "code": "OK",
  "data": {
    "upload_id": "450a8630-518f-452a-ba47-4f5f80758123",
    "kind": "image",
    "original_name": "artwork.jpg",
    "file_size": 39988,
    "width": 500,
    "height": 500,
    "expires_at": "2026-07-23T08:00:00+00:00",
    "url": "/storage/cart-images/550e8400-e29b-41d4-a716-446655440000.jpg"
  }
}
```

| 规则 | 说明 |
| --- | --- |
| 有效期 | 上传记录 7 天后失效。 |
| 归属 | 只能由上传它的 Client 在订单中使用。 |
| 印刷图片 | 可用于多个订单；通过 `images[].quantity` 指定使用次数。 |
| 运单 | 一张运单只能用于一个包裹；订单创建成功后被占用。 |
| 文件目录 | 图片写入原系统 `cart-images/`，运单写入原系统 `shipping-label/`。 |

## 6. 创建订单

```http
POST /api/open/v1/orders
Content-Type: application/json
X-Idempotency-Key: partner-10001-key
```

一个外部订单可包含多个 `shipments`；每个包裹生成一张内部 `factory_order`，并且每个包裹必须使用不同的、已上传的外部运单。

```json
{
  "external_order_no": "partner-10001",
  "shipments": [{
    "external_shipment_no": "parcel-a",
    "shipping_label_upload_id": "11111111-1111-4111-8111-111111111111",
    "items": [{
      "external_line_no": "line-a",
      "product_id": 29,
      "quantity": 2,
      "images": [
        {"upload_id":"22222222-2222-4222-8222-222222222222", "quantity":1, "degree":0},
        {"upload_id":"33333333-3333-4333-8333-333333333333", "quantity":1, "degree":90}
      ],
      "option": {"frame":"black"},
      "job_id": "partner-job-a",
      "comments": "Handle with care"
    }]
  }]
}
```

### 6.1 请求字段

| 字段 | 必填 | 规则 |
| --- | --- | --- |
| `external_order_no` | 是 | 当前 Client 内唯一，最长 128 字符。 |
| `shipments` | 是 | 包裹数组，数量 `1–100`。 |
| `shipments[].external_shipment_no` | 是 | 同一订单内唯一，最长 128 字符。 |
| `shipments[].shipping_label_upload_id` | 是 | 运单上传接口返回的 UUID。 |
| `shipments[].items` | 是 | 每包裹 `1–100` 个订单行。 |
| `external_line_no` | 是 | 第三方订单行号，最长 128 字符。 |
| `product_id` | 是 | 商品接口的 `variants[].id`。 |
| `quantity` | 是 | 整数 `1–100000`。 |
| `width`、`height` | 自定义尺寸时必填 | `custom_size` 必须传正数；固定尺寸如传入必须与商品目录一致。 |
| `images` | 是 | 图片数组，数量 `1–100`。 |
| `images[].upload_id` | 是 | 图片上传接口返回的 UUID。 |
| `images[].quantity` | 是 | 该图片使用数量，整数 `1–100000`。 |
| `images[].degree` | 否 | 旋转角度 `0–359`，默认 `0`。 |
| `option` | 否 | JSON 对象。 |
| `job_id` | 否 | 最长 255 字符。 |
| `comments` | 否 | 最长 2000 字符。 |

每个订单行必须满足：

```text
sum(images[].quantity) = quantity × upload_num
```

例如 `upload_num=1` 且购买 2 件，可以传两张图各 `quantity=1`，也可复用同一图并传 `quantity=2`。商品 `upload_num=2` 且购买 1 件时，图片总使用数量必须是 2。

### 6.2 幂等、库存和余额

- 相同 Client 下，使用相同订单号或幂等键、且请求内容相同时，重试返回已有订单（HTTP `200`）；内容不同返回 HTTP `409`。
- 系统先使用绑定账户已有库存，不足部分按当前价格从余额采购。
- 开启代理价格时，按每个订单行的 `quantity` 读取“折扣管理”中 `min_quantity <= quantity` 的最高门槛折扣价；没有匹配折扣时使用标准价。
- 余额或商品库存不足时，整个事务回滚，不创建订单。
- 成功后一个包裹对应一张 `factory_order`，并立即投递工厂下发队列。

首次成功创建返回 HTTP `201`：

```json
{
  "code":"OK",
  "message":"Order accepted and queued for factory dispatch.",
  "data": {
    "external_order_no":"partner-10001",
    "status":"submitted",
    "amount":25,
    "currency":"USD",
    "created_at":"2026-07-16T08:00:00+00:00",
    "factory_orders":[{
      "id":124335,
      "print_job_id":"20260716080000000-00000097",
      "status":"Ready To Proceed",
      "dispatch_status":"pending",
      "dispatch_error":null,
      "external_shipment_no":"parcel-a"
    }]
  }
}
```

命中幂等重试时返回 HTTP `200`，`message` 为 `Existing order returned.`。

## 7. 订单查询

### 7.1 查询订单列表

```http
GET /api/open/v1/orders?per_page=20
```

仅返回当前 Client 创建的订单。`per_page` 范围 `1–100`，默认 `20`；响应中的 `data` 是分页对象。

### 7.2 查询订单详情

```http
GET /api/open/v1/orders/partner-10001
```

返回字段与创建订单响应的 `data` 相同。找不到时：

```json
{"code":"ORDER_NOT_FOUND","message":"Order not found."}
```

| 字段 | 含义 |
| --- | --- |
| `status` | Open API 顶层状态：创建成功为 `submitted`，取消后为 `canceled`。 |
| `factory_orders[].status` | 内部工厂订单状态，如 `Ready To Proceed`、`Factory Shipped`、`Completed`、`Canceled`。 |
| `factory_orders[].dispatch_status` | 工厂接口下发状态：`pending`、`sent`、`failed`。 |
| `factory_orders[].dispatch_error` | 下发失败时的错误信息；无错误为 `null`。 |

## 8. 取消订单

```http
POST /api/open/v1/orders/partner-10001/cancel
```

订单创建后 **10 分钟内**可以取消；超过 10 分钟返回 HTTP `409`：

```json
{"code":"ORDER_CANCEL_WINDOW_EXPIRED","message":"Orders can only be canceled within 10 minutes of creation."}
```

取消成功后：

- 订单 `status` 变为 `canceled`，所有 `factory_orders[].status` 变为 `Canceled`；
- 出库库存记录作废，下单时的余额扣款退回原扣款账户；
- 订单占用的运单上传记录被释放，可在后续订单中再次使用；
- 已下发工厂的包裹会同步通知工厂取消；重复调用返回相同结果，不会重复退款。

响应 `data` 结构与订单详情相同，`message` 为 `Order canceled.`。

## 9. 余额查询

```http
GET /api/open/v1/balance
```

返回当前 Client 绑定结算账户的 USD 余额。

```json
{
  "code": "OK",
  "data": {
    "balance": 1250.75,
    "currency": "USD"
  }
}
```

| 字段 | 说明 |
| --- | --- |
| `balance` | 当前绑定结算账户的可用余额。 |
| `currency` | 余额币种，固定为 `USD`。 |

## 10. Webhook 回调

后台填写 `webhook_url` 后，系统通过队列异步回调；每次最多尝试 3 次，HTTP 超时 15 秒。

| Header | 说明 |
| --- | --- |
| `Content-Type` | `application/json` |
| `X-Open-Event` | 事件名称。 |
| `X-Open-Signature` | 对原始回调 JSON 字符串计算的 HMAC-SHA256。 |

签名密钥优先使用后台设置的 `webhook_secret`；未设置时使用 Client Secret。

事件：

- `order.created`：订单创建成功；
- `order.canceled`：订单在 10 分钟取消窗口内被取消；
- `factory_order.status_changed`：工厂订单状态被工厂回调更新。

```json
{
  "event":"order.created",
  "occurred_at":"2026-07-16T08:00:00+00:00",
  "data":{
    "external_order_no":"partner-10001",
    "status":"submitted",
    "factory_orders":[{"id":124335,"status":"Ready To Proceed","dispatch_status":"pending"}]
  }
}
```

接收端应尽快返回任意 `2xx`；非 `2xx` 会触发重试。建议按事件和订单号实现去重。

## 11. 错误码

业务错误格式：

```json
{"code":"ERROR_CODE","message":"Human-readable explanation."}
```

字段验证失败为 Laravel 标准 HTTP `422` 响应，包含 `message` 和 `errors`。

| HTTP | Code | 含义 |
| --- | --- | --- |
| 401 | `UNAUTHORIZED` | 认证头缺失/错误、时间戳过期、签名错误、Nonce 重复、Client/账户不可用、IP 不允许或文件摘要不匹配。 |
| 403 | `FORBIDDEN` | Client 缺少接口权限。 |
| 403 | `BALANCE_PERMISSION_DENIED` | 绑定账户不允许使用余额采购库存。 |
| 404 | `PRODUCT_NOT_FOUND` | 主产品不存在或已禁用。 |
| 404 | `ORDER_NOT_FOUND` | 当前 Client 没有该订单。 |
| 409 | `IDEMPOTENCY_CONFLICT` | 相同订单号或幂等键使用了不同请求内容。 |
| 409 | `CLIENT_CONFIGURATION_INVALID` | Client、绑定账户或公司配置不一致。 |
| 409 | `PRODUCT_CONFIGURATION_INVALID` | 固定尺寸商品缺少有效尺寸配置。 |
| 409 | `BALANCE_ACCOUNT_NOT_FOUND` | 绑定公司没有可用余额账户。 |
| 409 | `ORDER_CANCEL_WINDOW_EXPIRED` | 订单创建超过 10 分钟，不能取消。 |
| 422 | `INVALID_IDEMPOTENCY_KEY` | 幂等键超过 128 字符。 |
| 422 | `DUPLICATE_SHIPMENT_NUMBER` | 同一订单包裹号重复。 |
| 422 | `DUPLICATE_SHIPPING_LABEL` | 同一订单重复使用运单。 |
| 422 | `UPLOAD_UNAVAILABLE` | 上传文件过期、类型不符、不属于当前 Client 或运单已使用。 |
| 422 | `PRODUCT_UNAVAILABLE` | 商品或主产品不可售。 |
| 403 | `PRODUCT_NOT_AUTHORIZED` | 商品未授权给当前 Client。 |
| 422 | `INVALID_PRODUCT_SIZE` | 尺寸不合法或与固定尺寸目录不一致。 |
| 422 | `ARTWORK_QUANTITY_MISMATCH` | 图片总数量不等于 `quantity × upload_num`。 |
| 422 | `INSUFFICIENT_BALANCE` | 余额不足。 |
| 422 | `INSUFFICIENT_PRODUCT_STOCK` | 系统可采购商品库存不足。 |

## 12. 推荐对接顺序和运行要求

1. 后台创建 Client，安全保存 Client ID 和 Client Secret。
2. 查询商品，选择 `variants[].id`，记录 `upload_num`、尺寸和价格。
3. 上传每张印刷图片并保存 `upload_id`。
4. 上传每个包裹的外部运单并保存 `upload_id`。
5. 使用唯一幂等键创建订单，严格校验图片数量规则。
6. 通过创建响应、订单查询或 Webhook 跟踪工厂订单状态。

服务器必须运行队列 Worker，才能实际下发工厂订单与发送 Webhook：

```bash
php artisan queue:work
```

开发联调页：`https://{your-domain}/open-api-tester.html`。该页面仅供测试，Secret 仅保存在当前浏览器会话。

英文版请见：[Open API v1 Integration Guide (English)](open-api-v1.en.md)。
