Files
cmautobuy/docs/admin/08-顺运宝接口.md
T
chengmaandClaude Opus 5 512ccf34d6 docs: 新增顺运宝接口契约;config.yaml 加入 gitignore
从 4 份 HAR 抓包和示例脚本还原 docs/admin/08-顺运宝接口.md。
全部结论标了出处,只有单一样本支撑的都标了 [待定]。

抓包验出三件和现有假设不符的事:

1. 登录响应的 JWT 从不参与请求,认证全靠 Cookie。示例脚本里
   self.token 只用于算缓存有效期,没进过任何请求头。Go 侧存 Cookie
   即可,token 都不用存。

2. _capture_refreshed_token 是死代码——它从响应头 X-Requested-With
   读刷新后的 JWT,而 4 份 HAR 共 18 个响应里带该头的是 0 个。
   会话就是 24 小时硬上限,没有滚动续期,不要移植这段逻辑。

3. 金额单位在同一个响应里不统一:amtOrder 在列表接口是分(61200
   对应 612.0),escrowAmount 却不是(505 对应 505.0)。不能假设
   "列表接口的金额都是分",逐字段确认。这条只有一个样本,已标 [待定]。

还推翻了「货运单规格能直接对上蝦皮商品規格ID」这个前提:顺运宝给的
productId 是 11 位商品ID,蝦皮規格ID 是 12 位。但 productSpec 的格式
与蝦皮报表完全一致,可直接复用 #38 的 ParseSpec,匹配走
"productId 定位商品 → 解析规格 → 在该商品的 SKU 里比对"。

admin/config.yaml 含明文密码且此前没有任何 gitignore 规则挡它,
一次目录级 git add 就会进历史。已加规则,并补 config.example.yaml
作为模板(不含真实凭据,进 git)。

已确认历史提交中从未出现过该凭据。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-09 10:59:00 +08:00

400 lines
14 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 08 顺运宝 ERP 接口契约
- 文档状态:**从 HAR 抓包还原**,未经官方文档核对
- 来源:`raw_data/shunyunbaoerp_*.har`(4 份,2026-07-28 抓)
和 `raw_data/shunyunbaoerp_single.py`(962 行的可运行示例)
- 基址:`https://www.shunyunbaoerp.com`
本文档中 `[必须]` / `[建议]` / `[待定]` 的含义见 [文档索引](../README.md#文档标注说明)。
没有标注的默认是 `[必须]`。看不懂的词查 [术语表](00-glossary.md)。
> `[必须]` **`raw_data/` 和 `admin/config.yaml` 里有真实账号密码,两者都已在
> `.gitignore` 里,绝不能进 Git,也不要随打包产物发给别人。**
> 本文档里的账号、单号、金额全部是脱敏或示例值。
---
## 1. 这份文档是怎么来的,可信度如何
全部结论来自抓包和示例脚本,**不是官方文档**。凡是只有一个样本支撑的判断,
下面都标了 `[待定]`,实现时要在真实数据上再确认一次。
已核对过的 4 份抓包:
| 文件 | 覆盖 |
|---|---|
| `shunyunbaoerp_login.har` | 验证码、登录 |
| `shunyunbaoerp_stock_list.har` | **按日期范围**列表查询 |
| `shunyunbaoerp_stock_query.har` | 按单号查询 + 货运明细 |
| `shunyunbaoerp_userinfo.har` | 个人信息(用来探测会话是否还有效) |
---
## 2. 统一响应信封
所有 `/am/**` 接口都是这个形状:
```json
{ "status": true, "msg": "获取成功", "data": <任意>, "code": null }
```
`[必须]` 判断成功**只看 `status === true`**,不要看 HTTP 状态码——
服务端在业务失败时也可能返回 200。
`[必须]` `data` 的类型随接口变:可能是对象、数组,也可能是**裸整数**
(`listTotal` 就返回 `"data": 1`)。不要假设它一定是对象。
`[必须]` 失败时把 `msg` 和 `code` 一起带进错误信息,否则排查时看不出原因。
---
## 3. 认证
### 3.1 认证靠 Cookie,不是 token
`[必须]` **登录响应里的 JWT 从来不参与后续请求。**
已核对示例脚本全文:`self.token` 只出现在「登录时赋值 / 存缓存 / 读缓存 /
算缓存有效期」四处,**从没被放进任何请求头**。认证完全靠
`requests.Session` 自动维护的 Cookie。
所以 Go 侧要持久化的是 **Cookie**,token 只用来算过期时间——
甚至可以不存 token,直接存算好的 `expires_at`。
### 3.2 登录流程
```text
① GET /api/p/code1?<毫秒时间戳> → image/jpeg,约 2.3KB,4 位字母数字
② POST /am/auth/login → {"username","password","code"}
→ {"status":true,"data":{"user":{...},"token":"<JWT>"}}
```
`[必须]` 三步必须用**同一个 HTTP 客户端**(同一个 Cookie Jar)——
验证码是和会话绑定的,换客户端拿到的验证码对不上。
`[必须]` 密码**明文提交**(走 HTTPS)。客户端不做哈希。
`[必须]` 时间戳参数是为了绕开缓存,每次取验证码都要换。
### 3.3 会话有效期正好 24 小时
实测 JWT 载荷:
```json
{"authLogin": false, "exp": 1785294742, "iat": 1785208342,
"jti": "<用户名>", "username": "<用户名>"}
```
`exp - iat = 86400` 秒,**整 24 小时**。
`[必须]` 缓存有效期取 `min(自定义上限, JWT 剩余时间)`,缓存不能活得比会话长。
### 3.4 没有滚动续期
`[必须]` 示例脚本里的 `_capture_refreshed_token()` 从响应头
`X-Requested-With` 读刷新后的 JWT,**这是死代码**。
实测 4 份 HAR 共 18 个接口响应,**带该响应头的:0 个**。
(`X-Requested-With` 本来就是**请求**头,脚本自己也设了 `XMLHttpRequest`。)
**不要把这段逻辑移植到 Go。** 会话就是 24 小时硬上限,到点重新登录。
### 3.5 怎么判断会话还活着
```text
GET /am/user/get?id=<登录响应里的 user.id>
```
`[必须]` **必须区分「明确未登录」和「网络故障」**:
| 情况 | 处理 |
|---|---|
| HTTP 401 / 403 | 判定未登录,清会话 |
| `msg` 含「未登录」「登录过期」,或 `code=-2` | 判定未登录,清会话 |
| 超时、5xx、响应格式错 | **抛错,不要判定未登录** |
理由:网络抖一下就判定登出的话,会触发重新登录,验证码弹个不停,
而且可能把本来有效的会话丢掉。示例脚本这一点做对了,照抄。
`[必须]` 还要核对返回的 `id` / `username` 与缓存的一致——
不一致说明串号了,同样清会话。
---
## 4. 货运单列表
两个接口配合,**payload 完全相同**:
```text
POST /am/stock/listTotal → data 是裸整数,总条数
POST /am/stock/list → data.list 是数组
```
### 4.1 请求体
```json
{
"history": 0,
"length": 20, // 每页条数
"start": 0, // 偏移
"pageTotal": 0,
"pageIndex": 1, // 从 1 开始
"store": false,
"columns": [ ... 72 个列定义 ... ],
"queries": [ ... 查询条件 ... ]
}
```
`[必须]` `columns` 是**要返回哪些列**的声明,72 项,每项形如:
```json
{"tableName":"t_stock","colName":"created","fieldName":"created",
"hasAlias":0,"tableAlias":"t"}
```
完整清单见示例脚本的 `COLUMN_SPECS`(第 51–124 行)。
`[建议]` 直接照搬,不要自己删减——服务端可能依赖这批列做联表。
### 4.2 两种查询条件
**按日期范围(同步用这个)** —— 出自 `shunyunbaoerp_stock_list.har`:
```json
{"dvalue": "2026-07-25,2026-07-28", "tableName": "t_stock",
"colName": "created", "op": 0, "type": 3, "tableAlias": "t", "optType": 0}
```
`[必须]` `dvalue` 是 `起始日期,结束日期`,逗号分隔,`YYYY-MM-DD`。
`type: 3` 表示日期类型,`op: 0` 表示范围。
**按单号(查单条用这个)** —— 出自 `shunyunbaoerp_stock_query.har`:
```json
{"dvalue": "<单号>", "tableName": "t_stock",
"colName": "allcode", "op": 6, "type": 0, "tableAlias": "t", "optType": 1}
```
`[必须]` `queries` 是**数组**,理论上可以组合多个条件。
示例脚本里那句 `if not order_number: raise ValueError` 是**脚本自己的限制**,
接口没有这个限制。
`[待定]` 空 `queries`(不加任何条件)能否拉全量未验证。同步用日期范围就够,
不需要冒这个险。
### 4.3 分页
`[必须]` 先调 `listTotal` 拿总数,再按 `length` 翻页调 `list`。
`[必须]` **必须有单次同步的条数上限**,超了报错而不是硬拉。
示例脚本用的是 `max_matches = 100`。日期范围拉全量时这个上限要调大,
但不能没有——手滑填成一年的范围会把整库拉下来。
---
## 5. 货运单字段
`data.list[]` 每行 **77 个字段**。业务上要紧的:
| 字段 | 示例 | 说明 |
|---|---|---|
| `id` | `75104587` | **货运单主键**,取明细要用它 |
| `code` | `260728TB95MJTQ` | 单号(界面上搜的就是这个) |
| `created` | `2026-07-28 10:37:59` | 创建时间,日期范围筛的就是它 |
| `status` | `13` | 数字状态码 |
| `orderStatus` | `待出货` | 中文状态 |
| `purchaseStatus` | `0` | 采购状态 |
| `shopName` | `<店铺名>` | 蝦皮店铺 |
| `productName` | `純棉上衣` | **只有一个商品名**,一单多商品时不完整 |
| `orderQty` / `detailQty` | `2` / `2` | 商品件数 |
| `isCancel` | `0` | 是否取消 |
| `expCompany` | `蝦皮店到店` | 物流方式 |
| `receiver` / `receiverTel` / `receiverAddr` | — | **收件人信息,属个人数据** |
`[必须]` **收件人姓名、电话、地址属于个人信息**,落库要考虑是否必要。
不做采购决策用不到它们,`[建议]` 不入库,或只存脱敏后的。
### 5.1 金额单位不统一 —— 最容易算错钱的地方
`[必须]` 实测同一张单、同一行里,两个金额字段**单位不一样**:
| 字段 | `/am/stock/list` | `detail/listByStock` | 关系 |
|---|---|---|---|
| `amtOrder` | `61200` | `612.0` | list 是**分**,×100 |
| `escrowAmount` | `505` | `505.0` | list **不是分**,未 ×100 |
`61200 / 100 = 612` 对得上,`505 / 100 = 5.05` **对不上**。
`[必须]` **不要假设「列表接口的金额都是分」。** 逐个字段确认,
并且在代码里为每个金额字段写清它的单位。
`[建议]` 金额一律以 **`detail/listByStock` 的值为准**,那边是统一的元/TWD。
需要存整数分时自己 ×100,不要用列表接口的原值。
`[待定]` 只有一个样本。实现前**必须再取几张单核对**,尤其是
`escrowAmount` 不是整数元的情况。
### 5.2 金额合计对不上是正常的
```text
明细单价合计 239.0 + 439.0 = 678.0
amtOrder 612.0
```
差 66,应该是优惠。`[必须]` **不要用「明细合计 == amtOrder」做校验**,
会误报。
---
## 6. 货运明细
```text
POST /am/stock/detail/listByStock?hist=0
{"ids": [75104587, ...]}
→ data.list[]
```
`[必须]` 一次最多传 **100 个 id**(示例脚本的分批大小),超了分批。
### 6.1 一单多商品是嵌套,不是多行
返回的每一行对应**一张货运单**,商品在嵌套的 `details[]` 里:
```json
{
"id": 75104587, // 与 stock.id 相同,可直接对应
"code": "260728TB95MJTQ",
"shopName": "<店铺名>",
"productName": "純棉上衣",
"amtOrder": 612.0,
"details": [
{
"id": 145306175,
"productId": 50209124255,
"productTitle": "蕾絲花邊拼接背心女 上衣 背心 無袖打底衫 …",
"detailProductName": "打底衫",
"productSpec": "白色,L【建議50-60公斤】",
"productQty": 1,
"productPrice": 239.0,
"productThumb": 190639637,
"shopId": 999611342,
"pruchaseId": 38884195
},
{ ... 第二个商品 ... }
]
}
```
`[必须]` 外层 `id` **就是** `stock.id`,可以直接按它把列表和明细对起来。
`[必须]` **一张货运单可以有多个商品**(示例这张就有 2 个)。
落库时必须一对多拆开,不能只取 `productName`——那个字段只有一个商品名。
### 6.2 `productId` 是蝦皮商品 ID,不是規格 ID
`[必须]` 这一条推翻了之前「货运单的规格 SKU 能直接对上蝦皮商品規格ID」的假设。
| | 位数 | 例 |
|---|---|---|
| 顺运宝 `productId` | 11 | `50209124255` |
| 蝦皮 `商品ID` | 11 | 实测导入的 5195 个**全是 11 位** |
| 蝦皮 `商品規格ID` | 12 | 实测 5742/6092 是 12 位 |
顺运宝给的是**商品级 ID + 规格原文**,没有規格 ID。
### 6.3 `productSpec` 的格式和蝦皮报表完全一致
```text
蝦皮 Excel「商品規格」 黑色,M【建議40-50公斤】
顺运宝 productSpec 白色,L【建議50-60公斤】
黑色+白色【純棉兩件裝】 簡約親膚,L【建議52.5-60公斤】
```
`[必须]` **直接复用 `service.ParseSpec()`**(#38 实现),不要另写一份解析。
两处各写一份,规则迟早不一致,而解析出的颜色尺码要拿去下单。
`[必须]` 第二个例子里颜色部分自带 `【】`,`ParseSpec` 已覆盖这种情况
(按第一个逗号切开)。
### 6.4 由此推导出的匹配路径
```text
productId ──→ shopee_products.goods_id 商品级,直接相等
productSpec ──→ ParseSpec() ──→ 颜色 + 尺码
──→ 在该商品的 shopee_skus 里比对 ──→ 拿到 sku_id
```
`[必须]` 比对不上时**不要猜**,标成待人工匹配。蝦皮报表只含有销售成绩的
SKU(平均每商品 1.17 个),**查无此 SKU 是常态**,不是异常。
---
## 7. 其他接口
| 接口 | 用途 | 备注 |
|---|---|---|
| `POST /am/store/listByUsed` | 仓库列表 | 请求体为空;返回 `[{"name":"京发仓","id":129}, …]` |
| `POST /am/wallet/showTip` | 登录后弹提示 | 同步不需要 |
| `GET /am/menu/curr` | 当前菜单 | 同步不需要 |
| `POST /am/notice/myList` | 通知列表 | 同步不需要 |
| `GET /am/user/checkNeedAgreement` | 是否需同意协议 | 同步不需要 |
`[建议]` 登录后**只调 `/am/user/get` 验证会话**,其余几个是网页自己的初始化请求,
Go 侧不用跟着调。
---
## 8. 实现时的固定约束
这几条不是抓包结论,是本项目的决定,写在这里避免每次重新讨论:
`[必须]` **会话缓存存 SQLite,不引入 Redis。** Admin 的定位是「双击 exe 就能跑」,
`data/` 在 exe 旁边。示例脚本用 Redis 是因为它是反复启动的一次性脚本,
进程间要传会话;Admin 是常驻进程,没有这个需求,持久化只为重启后免登录。
`[必须]` **不引入 OCR 服务。** 会话 24 小时,一天登录一次。
为省一次手工输验证码而依赖 `127.0.0.1:8000` 不划算——多一个必须先启动的东西,
而且 OCR 会失败(示例脚本自己写了 5 次重试),失败了照样要人工。
界面上显示验证码图片、操作员输一次即可。
`[必须]` **凭据放 `admin/config.yaml`**,已在 `.gitignore` 里。
仓库里提供 `admin/config.example.yaml` 作为模板(不含真实凭据)。
`[必须]` **密码在任何日志里都要打码。** 日志可能被贴进工单排查问题。
`[必须]` YAML 里**密码要加引号**。纯数字密码不加引号会被 YAML 解析成整数,
反序列化到 `string` 字段直接报错:
```yaml
password: 0012345 # ✗ 解析成整数 12345,前导 0 也丢了
password: "0012345" # ✓
```
`[建议]` 依赖用 `github.com/goccy/go-yaml`,它已经在依赖树里(indirect),
不会引入新的传递依赖。
---
## 9. 已知未验证的部分
实现前应逐条确认,都只有单一样本支撑:
- [ ] `escrowAmount` 等金额字段在列表接口里的**单位**(见 §5.1)
- [ ] 空 `queries` 能否拉全量(同步用日期范围,可不验)
- [ ] 日期范围跨度很大时服务端是否限流或超时
- [ ] `status` 数字码与 `orderStatus` 中文的完整对应表
- [ ] 会话失效时服务端返回的**确切**形态(HTTP 码 / `code` / `msg` 文案)
- [ ] 同一账号多处登录是否互踢
- [ ] 验证码错误、密码错误分别返回什么,能否区分
`[必须]` 最后两条影响错误提示的准确性:分不清「密码错」和「验证码错」的话,
操作员会一直重输密码。
---
## 10. 相关文档
- 数据落库:[03 数据模型](03-data-model.md)
- 界面:[05 界面规范](05-ui-specification.md) §6 顺运宝数据页
- 安全要求:[06 质量与安全](06-quality-security.md)
- 规格解析:`admin/service/shopee_import.go` 的 `ParseSpec()`