# KJLong 开放接口 · Agent 对接文档

> 本文件面向自动化程序 / AI Agent，为机器可读的完整契约。人类可读版：https://kjlong.com/developers
> 契约版本：v1.2（2026-07-25）。以本文件为准实现，msg 原文可能微调，**分支判断只依据 code**。

## 基础

- Base URL：`https://api.kjlong.com`
- 鉴权：每个请求带请求头 `Authorization: Bearer <token>`。**两种令牌共用这一个头**，见「令牌类型」。
- 所有响应为统一信封：`{"code": int, "msg": string, "data": any, "success": bool}`
  - 成功：`code == 0` 且 `success == true`，业务数据在 `data`
  - 失败：`success == false`，按 `code` 分支（HTTP 状态码同样语义化，可一并参考）
- **请求体里的 storeId 字段一律被忽略**，任何情况下都不要传。店铺由令牌（`sk_`）或请求头 `X-Store-Id`（`ak_`）决定。
- 时间字段格式一律 `yyyy-MM-dd HH:mm:ss`。金额为元（两位小数），币种为店铺币种。
- 限流与配额见「限流与配额」一节（v1.2 起口径有变）。

## 令牌类型

| | `sk_` 店铺级 | `ak_` 账号级 |
|---|---|---|
| 作用范围 | **一店一枚**，绑死单个店铺 | 覆盖签发账号**名下全部店铺** |
| 来源 | 商户后台「设置 → 开发者 → 店铺 API Token」生成 | 商户后台授权 Chrome 采集插件时签发（「设置 → 开发者 → Chrome 采集插件」） |
| 数量 | 每店一枚（重新生成即作废旧的） | 一个账号可有多枚（多设备各一枚），**可逐枚单独吊销** |
| storeId 来自 | **令牌本身** | 请求头 `X-Store-Id` |
| `X-Store-Id` | **不需要也不应该带**（带了会被忽略） | 调店铺维度端点时**必须带** |
| 可调端点 | 除 `/openapi/store/list` 外全部 | 全部 |

**`sk_` 的行为与 v1.1 完全一致**，已接入方无需任何改动。

### `ak_` 的 `X-Store-Id` 规则

- 值为目标店铺的数字 id（从 `/openapi/store/list` 获取）。
- 后端校验该店铺 `owner_account_id` 是否等于令牌所属账号，**不通过即拒绝**。
- 缺失或不是数字 → **4100 / 400**（令牌本身没问题，纯参数问题）。
- 指向的店铺不存在，**或存在但不属于该账号** → **4404 / 404**。**两种情况故意不区分**，避免把接口变成「storeId 是否存在」的探测器。
- 唯一不带 `X-Store-Id` 的端点是 `/openapi/store/list`（它天然没有店铺维度）。

## 限流与配额

三个独立的计数桶，互不抵扣：

| 桶 | 维度 | 额度 | 可配置 |
|---|---|---|---|
| 小时限流 | **按店铺** | 默认 600 次/时（UTC 整点固定窗口） | ✅ 平台后台可调，**0 = 不限** |
| 账号兜底 | **按账号** | 固定 120 次/时（UTC 整点固定窗口） | ❌ 不可配 |
| 上架日限额 | **按店铺** | 默认 200 次/天（UTC 日切） | ✅ 平台后台可调 |

**小时限流（按店铺，默认 600/时）**
- 同一店铺下 `sk_` 与 `ak_` **共享**该店铺的桶——换令牌不能换额度。
- `ak_` 打不同的店铺各走**各自独立的桶**（打 3 家店 ≈ 3 份额度）。
- **被拒绝的调用同样计数、且不回滚**（含 4100/4200/4404 等业务失败）。
- 超限 → 4004 / 429，msg 中的数字为该店铺当前生效额度。

**账号兜底桶（按账号，固定 120/时，全设备共享）**
命中它的只有两类请求：
1. `/openapi/store/list` 的正常调用（该端点没有店铺维度，只计这个桶，不碰任何店铺的桶）；
2. **任何未通过归属校验的请求**——缺 `X-Store-Id`、`X-Store-Id` 非法、或指向不属于本账号的店铺。这类请求落不到任何店铺桶上，若不计数即等于绕过限流。

> ⚠️ **不要高频轮询 `/openapi/store/list`**：它只吃 120/时 的账号桶，且该桶被账号下所有设备共享。**本地缓存店铺列表**，仅在用户打开店铺选择器 / 新建店铺后重拉。

**上架日限额（默认 200/天）**
- 只有 `/openapi/product/create` 消耗，**且仅成功时消耗**（参数、图片、引用校验失败不扣）。
- 按店铺计、UTC 日切。`ak_` 打不同店铺各有独立日限额。
- 超限 → 4003 / 429；其他端点不受影响。

## 错误码与重试策略

| code | HTTP | 含义 | Agent 应对 |
|---|---|---|---|
| 0 | 200 | 成功 | — |
| 4001 | 401 | 无效的 API Token；或用 `sk_` 调了仅 `ak_` 可用的端点 | **停止并告警**，勿重试。msg 为「该接口仅支持账号级 Token（ak_）」时属调用方用错令牌类型，改用 `ak_` |
| 4002 | 403 | API Token 已停用（含 `ak_` 被商户吊销） | **停止并告警**，勿重试；提示用户重新授权 |
| 4003 | 429 | 已达今日上架限额（N） | 等待次日（UTC）再上架；其他端点不受影响 |
| 4004 | 429 | 调用过于频繁（店铺桶 N 次/时，或账号兜底桶 120 次/时） | 退避 ≥60 秒后重试 |
| 4100 | 400 | 参数错误（msg 指明具体字段）；**含 `X-Store-Id` 缺失或非数字** | 修正请求后重试，**原样重试必然再失败** |
| 4200 | 400 | 图片错误（msg 指明第几张与原因） | 修正对应图片后重试 |
| 4300 | 400 | 图片引用错误（文件名/key 对不上） | 修正引用后重试 |
| 4404 | 404 | 商品不存在（含不属于本店）；**或 `X-Store-Id` 指向的店铺不存在／不属于本账号** | 勿重试。店铺维度的 4404 应重拉 `/openapi/store/list` 校正 storeId |
| 4405 | 423 | 商品已锁定，禁止通过 API 编辑 | 跳过该商品；如需编辑，提示商户在后台解除锁定 |
| 5000 | 500 | 服务端错误 | 可重试一次，仍失败则告警 |

### ⚠️ 畸形请求不走本错误码体系

请求在被路由到端点**之前**就被判非法时（例如给 multipart 端点发了非 multipart 的 body、Content-Type 不匹配、用错 HTTP 动词），异常在 handler 映射阶段抛出，**根本没进入 openapi 的错误处理**。此时返回的是全局信封：

```json
{"code":2,"msg":"error_system"}
```

HTTP 状态 **500**，`code` 是 `2` 而不是任何 `4xxx`。

**Agent 必须这样处理**：分支判断前先确认 `code` 是 4xxx（或 0）。拿到 `code == 2` / `msg == "error_system"` 时，**当作「本地请求构造错误」处理**——检查 method、Content-Type、multipart 编码、字段名——**不要**当成服务端故障去重试，原样重试必然再失败。

## 商品锁定（apiLocked）

- 每个商品有锁定开关 `apiLocked`(1=锁定/0=开放)：**锁定的商品无法通过编辑接口修改**（读取不受影响）。
- 默认规则：**API 创建的商品 = 0（可编辑）**；商户后台人工创建的 = 1（锁定）。商户可在后台随时切换。
- 列表与详情接口均返回 `apiLocked`——**编辑前先检查该值**，为 1 则跳过，不要撞 4405。
- payload 中传入 `apiLocked` 会被**忽略**：API 无法锁定或解锁商品，开关只属于商户后台。

## 通用概念：图片引用

- 上传图片 = multipart 的 `images` 文件 part（可多个）。**part 顺序即图集顺序，第一张为主图。**
- 单图 ≤10MB；类型仅 gif/jpg/jpeg/webp/png；单次请求总大小 ≤100MB；单商品图集上限 100 张。
- 在 payload 中引用图片有两种形式：
  1. **本次上传文件的文件名**（如 `"black.png"` 对应 `-F 'images=@black.png'`）
  2. **现有图的 key**（详情接口或 `media/upload` 返回的 `key`；仅编辑接口可用作 `payload.images` 项）
- 任一引用解析不到 → 整个请求失败（4300），不会产生半成品。

**图片生命周期保证（可依赖）：**
- 上传的图片归属店铺素材库，图集 `images[]` 只是引用。**从图集移除仅删除引用，文件与其 URL 长期有效**。
- 因此以下用法受支持：图片经 `images` part 上传（或走 `media/upload`）→ 拿到 `url` → 嵌入 `description` HTML → 图集里不引用它。URL 不失效。
- 唯一会真正删除文件的操作是商户在后台素材库手动删除。且后台删除前会扫描本店商品的主图 / 图集 / 富文本描述 / 规格图，**若该图的 key 出现在任一处（含 `description` HTML），删除会被拒绝**——嵌进详情的图不会被误删。
- 同一文件重复上传按内容去重，返回同一 key/URL，不产生冗余。

## 推荐接入流程

**采集类场景（把外站商品搬进店铺）——一次 create 搞定，不要绕路：**

1. `GET /openapi/store/list`（仅 `ak_`）→ 拿 storeId，**本地缓存**
2. `GET /openapi/category/list` → 拿 `categoryCode`，本地缓存
3. `POST /openapi/media/upload` → 把详情长图转存，拿到 `[{key,url}]`
4. 把第 3 步的 `url` 拼进 `description` HTML
5. `POST /openapi/product/create` → **一次**完成建品（商品图集走本请求的 `images` part，详情图已在 description 里）

> v1.1 时代只能走「create → get 取 url → update 回填 description」，比上面多两次请求，且中间存在「详情图缺失」的脏态。**现在不要再用那条路径。**

---

## 1. 店铺列表

`GET /openapi/store/list`

- **仅 `ak_` 可调**。用 `sk_` 调用 → 4001，msg 为「该接口仅支持账号级 Token（ak_）」（`sk_` 绑死单店，「名下所有店」对它没有意义）。
- **不带 `X-Store-Id`。**
- 不分页，返回该账号名下全部店铺。
- 限流只走**账号兜底桶（120/时）**，不消耗任何店铺的小时额度，也不消耗上架日限额。

成功 `data`：`[{id, name, subdomain, currency, status}, ...]`

| 字段 | 类型 | 说明 |
|---|---|---|
| id | long | 店铺 id，即后续请求的 `X-Store-Id` 值 |
| name | string | 店铺名 |
| subdomain | string | 买家端子域名 |
| currency | string | 店铺币种 |
| status | int | 1=启用 0=停用 |

示例：
```bash
curl -G https://api.kjlong.com/openapi/store/list \
  -H "Authorization: Bearer ak_xxx"
```

## 2. 分类列表

`GET /openapi/category/list`

- **两种令牌都可调**。返回当前店铺（`sk_` = 令牌所属店；`ak_` = `X-Store-Id` 指定店）的分类。
- 按 `sort` 升序，**不按 status 过滤**——与 `product/create` 里 `categoryCode` 的解析口径一致（下架分类同样能被引用），避免出现「列表里没有但 create 能用」的割裂。
- 消耗店铺小时限流，不消耗上架日限额。

成功 `data`：`[{code, name, sort, status}, ...]`。`code` 就是 `product/create`、`product/update`、`product/list` 里 `categoryCode` 要填的值（不是 id）。

示例（`sk_`）：
```bash
curl -G https://api.kjlong.com/openapi/category/list \
  -H "Authorization: Bearer sk_xxx"
```

示例（`ak_`，指定店铺 3）：
```bash
curl -G https://api.kjlong.com/openapi/category/list \
  -H "Authorization: Bearer ak_xxx" \
  -H "X-Store-Id: 3"
```

## 3. 图片上传

`POST /openapi/media/upload`（multipart/form-data）

- **两种令牌都可调。** 文件字段名固定 `images`（可重复，1~100 个）。
- 只把图片存进**店铺素材库**并返回地址，**不创建商品**。
- **不消耗上架日限额**（日限额的语义是「上架了多少商品」，传图不是上架）；**消耗店铺小时限流**。
- 校验口径与 `product/create` 一致：单图 ≤10MB、类型仅 gif/jpg/jpeg/webp/png、单请求总大小 ≤100MB、最多 100 张。任一张失败**整批失败**并指明第几张。
- 按内容去重（同店同图秒传），重复上传返回同一 key/URL。

成功 `data`：`[{key, url}, ...]`，**顺序与入参 part 顺序一致**。
- `key`：裸存储键，可直接填进 `product/create` / `product/update` 的图片引用位。
- `url`：公网可读地址，可直接嵌进 `description` HTML。

失败：缺文件 / 超张数 / 单图超限 / 格式不符 → **4200**，msg 如 `缺少 images 文件`、`图片最多 100 张`、`第 N 张图片为空`、`第 N 张图片（f）超过 10MB`。

示例（`sk_`）：
```bash
curl -X POST https://api.kjlong.com/openapi/media/upload \
  -H "Authorization: Bearer sk_xxx" \
  -F 'images=@detail_01.jpg' -F 'images=@detail_02.jpg'
```

示例（`ak_`，指定店铺 3）：
```bash
curl -X POST https://api.kjlong.com/openapi/media/upload \
  -H "Authorization: Bearer ak_xxx" \
  -H "X-Store-Id: 3" \
  -F 'images=@detail_01.jpg' -F 'images=@detail_02.jpg'
```

## 4. 上架商品

`POST /openapi/product/create`（multipart/form-data）

Part：`payload`（JSON 文本，≤1MB）+ `images`（文件 ×0~100）。消耗上架日限额（仅成功时）。

payload 字段：

| 字段 | 类型 | 必填 | 说明 |
|---|---|---|---|
| title | string | ✅ | ≤255 字 |
| sub | string | | 副标题/卖点 |
| description | string | | HTML 富文本（图片 URL 可来自 `media/upload`） |
| categoryCode | string | | 店内分类 code（见 `category/list`）；不存在则 4100 报错 |
| status | int | | 0=下架 1=上架，默认 1 |
| aiShortTitle | string | | AI 客服用短名称，≤128 |
| aiPrompt | string | | 商品级 AI 提示词，≤2000 |
| price / origPrice / stock | number/number/int | 简单模式必填 price>0 | **简单模式**：生成单个 spec="Standard" 的 SKU |
| skuGroups / skus | array | skus 非空即**高级模式** | 见下 |

高级模式：
- `skuGroups[]`：`{name, opts[], imageEnabled?, optImages?{opt: 图片引用}}`
- `skus[]`：`{spec(必填, 按 skuGroups 顺序以 " / " 拼接), skuCode?(缺省取 spec), price, origPrice?, stock, status?(默认 1), image?(图片引用)}`
- status≠1 的 SKU 不参与商品最低价/总库存聚合

成功 `data`：`{"id": long, "title": string, "status": int, "url": string|null}`（url 为买家端商品页）

示例（简单模式）：
```bash
curl -X POST https://api.kjlong.com/openapi/product/create \
  -H "Authorization: Bearer sk_xxx" \
  -F 'payload={"title":"Open API Test Phone","sub":"via openapi","categoryCode":"phones","price":199.00,"origPrice":299.00,"stock":50,"description":"<p>Hello <b>world</b></p>"}' \
  -F 'images=@img_red.png' -F 'images=@img_green.png' -F 'images=@img_blue.png'
```

示例（高级模式，规格图按文件名引用）：
```bash
curl -X POST https://api.kjlong.com/openapi/product/create \
  -H "Authorization: Bearer sk_xxx" \
  -F 'payload={"title":"Advanced Phone","categoryCode":"phones","status":1,"skuGroups":[{"name":"Color","opts":["Black","White"],"imageEnabled":true,"optImages":{"Black":"img_black.png","White":"img_white.png"}},{"name":"Storage","opts":["128GB","256GB"],"imageEnabled":false}],"skus":[{"spec":"Black / 128GB","skuCode":"BLK-128","price":300,"origPrice":350,"stock":10,"image":"img_black.png"},{"spec":"Black / 256GB","skuCode":"BLK-256","price":350,"stock":8,"image":"img_black.png"},{"spec":"White / 128GB","skuCode":"WHT-128","price":320,"stock":5,"image":"img_white.png"},{"spec":"White / 256GB","skuCode":"WHT-256","price":370,"stock":0,"status":0,"image":"img_white.png"}]}' \
  -F 'images=@img_black.png' -F 'images=@img_white.png'
```

示例（`ak_`，把商品建到店铺 3；详情图先经 `media/upload` 转存后嵌进 description）：
```bash
curl -X POST https://api.kjlong.com/openapi/product/create \
  -H "Authorization: Bearer ak_xxx" \
  -H "X-Store-Id: 3" \
  -F 'payload={"title":"Collected Phone","categoryCode":"phones","price":199.00,"stock":50,"description":"<p><img src=\"https://res.kjlong.com/store/3/9c1e2f.jpg\"></p>"}' \
  -F 'images=@main_01.jpg'
```

## 5. 商品列表

`GET /openapi/product/list?page=1&size=20&status=&categoryCode=&keyword=`

| 参数 | 说明 |
|---|---|
| page | 1 起，默认 1 |
| size | 默认 20，上限 100（超限自动收敛） |
| status | 可选，0 或 1 |
| categoryCode | 可选，店内分类 code |
| keyword | 可选，标题模糊匹配 |

成功 `data`：`{"list": [...], "page", "size", "total", "totalPages"}`。
list 项为裁剪投影（**不含 description**，全文取详情接口）：
`{id, title, sub, price, origPrice, stock, status, apiLocked, categoryCode, mainImage(完整 URL), createTime, modifyTime}`

示例（`ak_`，读店铺 3）：
```bash
curl -G https://api.kjlong.com/openapi/product/list \
  -H "Authorization: Bearer ak_xxx" \
  -H "X-Store-Id: 3" \
  -d page=1 -d size=20 -d status=1
```

## 6. 商品详情

`GET /openapi/product/get?id={productId}`

成功 `data` 关键结构（其余为常规标量字段）：
- `mainImage`: `{key, url}` 或 null
- `images`: `[{key, url}, ...]`——**key 用于编辑接口引用，url 用于展示**
- `skuGroups`: 解析后的结构（optImages 值为 URL）或 null
- `skus`: `[{id, spec, skuCode, price, origPrice, stock, status, image: {key,url}|null}, ...]`
- `aiShortTitle`, `aiPrompt`, `url`(买家端商品页), `createTime`, `modifyTime`

不存在或不属于本店 → 4404（两种情况不区分）。

## 7. 编辑商品

`POST /openapi/product/update`（multipart/form-data）——**部分更新**

Part：`payload`（JSON，**id 必填**）+ 可选 `images` 文件 part。**不消耗**上架日限额。

语义规则（重要）：
1. **只改 payload 里出现的字段**，未出现的保持原值
2. `images` / `skus` / `skuGroups`：出现即**整体替换**（`"images": []` = 清空图集），未出现则原样保留
3. `payload.images[]` 每项 = 现有图 key（详情或 `media/upload` 返回的）或本次上传文件的文件名，数组顺序即最终图集顺序
4. `skus[].image`、`skuGroups[].optImages` 的引用规则同上
5. 顶层 `price/stock/origPrice` 在编辑中**不生效**——改价改库存必须走 `skus`
6. 未提及的 AI 素材、SKU、图集不会被清除

成功 `data`：同上架 `{id, title, status, url}`

示例（只改标题，其余全部不动）：
```bash
curl -X POST https://api.kjlong.com/openapi/product/update \
  -H "Authorization: Bearer sk_xxx" \
  -F 'payload={"id":412,"title":"New Title Only"}'
```

示例（重排图集：保留两张现有图并插入一张新图）：
```bash
curl -X POST https://api.kjlong.com/openapi/product/update \
  -H "Authorization: Bearer sk_xxx" \
  -F 'payload={"id":412,"images":["store/1/aaa.jpg","new_photo.png","store/1/bbb.jpg"]}' \
  -F 'images=@new_photo.png'
```

示例（`ak_`，改店铺 3 的商品）：
```bash
curl -X POST https://api.kjlong.com/openapi/product/update \
  -H "Authorization: Bearer ak_xxx" \
  -H "X-Store-Id: 3" \
  -F 'payload={"id":412,"title":"New Title Only"}'
```

---

## Agent 实现清单（照此自检）

- [ ] 每个请求带 `Authorization: Bearer <sk_ 或 ak_>`
- [ ] 用 `ak_` 时，**除 `/openapi/store/list` 外每个请求都带 `X-Store-Id: <storeId>`**（数字）；用 `sk_` 时不带（带了也被忽略）
- [ ] `/openapi/store/list` 只认 `ak_`；用 `sk_` 调它会拿 4001
- [ ] **不高频轮询 `/openapi/store/list`**：本地缓存店铺列表，只在用户切店/新建店铺时重拉（该端点只吃 120/时 的账号兜底桶，全设备共享）
- [ ] 按 `success`/`code` 分支，重试策略遵循错误码表（4001/4002 停止告警；4004 退避 ≥60s；4100 系修正后再试；店铺维度 4404 重拉 store/list 校正 storeId）
- [ ] **分支前先确认 code 是 4xxx**；拿到 `{"code":2,"msg":"error_system"}` + HTTP 500 时按「本地请求构造错误」处理（查 method / Content-Type / multipart 字段名），不要重试、不要报服务端故障
- [ ] 不在任何请求体里传 storeId
- [ ] 详情长图走 `media/upload` 转存后把 `url` 嵌进 `description`，再**一次** `product/create`；不要用「create → get → update」老路径
- [ ] `categoryCode` 取自 `category/list` 的 `code`（不是 id），本地缓存
- [ ] 上架前自查：title ≤255、图 ≤100 张且单张 ≤10MB、总请求 ≤100MB、高级模式每个 sku 有 spec
- [ ] 编辑前检查 `apiLocked == 0`（锁定商品跳过，收到 4405 提示商户后台解锁）
- [ ] 编辑时先调详情拿 `images[].key`，再构造 payload.images；牢记「传了即整体替换」
- [ ] 改价/改库存走 `skus`，不要用顶层 price/stock
- [ ] 限流节流自管：小时限流**按店铺**计（默认 600/时，平台可配，0=不限；同店 `sk_`/`ak_` 共享同一桶，`ak_` 打不同店各有独立桶），被拒调用同样计数
- [ ] 上架日限额自管：默认 200/天、按店铺、UTC 日切，只有成功上架消耗额度
