Files
cmautobuy/docs/admin/08-顺运宝接口.md
T
chengmaandClaude Opus 5 5e426cacf6 feat: 顺运宝货运单同步 (#46)
顺运宝模块此前是骨架,「同步」点了提示"待接入"。5195 个蝦皮商品已经
进系统,但货运单(真实订单)一条都没有,后面的规格匹配无从谈起。

按接口契约(docs/admin/08,从 4 份 HAR 还原)实现:配置、登录(界面
手工输验证码)、会话缓存到 SQLite、按日期范围增量同步、落 syb_orders。

shopee_sku_id 绝不被同步覆盖。它是规格匹配的结果,顺运宝那边根本没有
这个值(只给 11 位商品ID,蝦皮規格ID 是 12 位)。同步写进去就是写空,
把人工攒的匹配成果洗掉且不报错。它只出现在 INSERT 列清单里,不在
DO UPDATE SET 里;repository 层和 service 端到端各有一个测试守着。

增量从「上次同步日期当天」重拉,不是第二天。created 筛选粒度是日期而
last_synced_at 精确到秒,从第二天拉会漏掉当天晚些时候创建的单且不报错。
宁可重复拉(upsert 幂等)也不能漏。中途失败不更新 last_synced_at,
否则下次跳过这段区间,漏的单永远补不回来。

日期运算用 UTC+8,不是 UTC。审查时从 HAR 确认 created 是当地时间:
抓包于 2026-07-28T03:31:45Z(= 11:31 UTC+8),同一响应里 created 是
"2026-07-28 10:37:59";若它是 UTC 则等于 18:37 UTC+8,比抓包晚 7 小时,
订单创建于未来,不成立。用 UTC 算会在本地 00:00-08:00 把"今天"算成昨天,
当天早晨的单这轮拉不到。用 time.FixedZone 写死,不用 LoadLocation——
那要读系统 tzdata,Windows 默认没有,打包成 exe 会失败。

金额一律取 detail/listByStock 的值:08 §5.1 实测同一响应里 amtOrder
在列表接口是分、escrowAmount 却不是,单位不统一,取错差 100 倍。

迁移 v5 纯追加(syb_session、syb_sync_state、syb_orders.product_spec),
v1-v4 逐字未动,CheckSchema 覆盖新表新列。

会话有效性判断把「网络故障」和「明确未登录」的分类集中在 Client.do()
一处——网络抖一下就判定登出的话,验证码会弹个不停,还会丢掉有效会话。

测试全部用 httptest 假服务端,不打真实站点。

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

430 lines
16 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」做校验**,
会误报。
### 5.3 `created` 是 UTC+8,不是 UTC —— 日期范围查询最容易算错的地方
`[必须]` 实测 `raw_data/shunyunbaoerp_stock_query.har`:
```text
HAR 记录的抓包时刻 startedDateTime 2026-07-28T03:31:45Z (= 11:31:45 UTC+8)
同一次请求响应里的 created 2026-07-28 10:37:59
```
`10:37:59` 作为 **UTC+8** 讲得通(比抓包时刻早 54 分钟,正常)。
若把它当成 **UTC**,换算成 UTC+8 就是 18:37,比抓包时刻**晚 7 小时**——
订单创建于尚未发生的未来,不成立。所以 `created` 是 UTC+8,不是 UTC。
`[必须]` §4.2「按日期范围」的 `dvalue` 筛的就是这个 `created`,
所以**换算"今天是哪一天"也必须用 UTC+8**,不能用 UTC 或本机系统时区
(本机系统时区不一定是 UTC+8,取决于部署环境)。用 UTC 算的话,
在 UTC+8 的 00:00–08:00 这段时间会把"今天"算成昨天,当天早晨创建的单
这一轮同步拉不到——虽然下一轮的起始日期仍是"上次同步日",范围会覆盖
回来、不会永久丢单,但操作员当场点同步会以为同步坏了。
`[必须]` 代码里固定用 `time.FixedZone("UTC+8", 8*60*60)`,不要用
`time.LoadLocation("Asia/Shanghai")`——那个要读系统 tzdata,Windows 上
默认没有,打包成 exe 后会在运行时报错。
`[待定]` 只有一个样本(一次抓包)支撑这个结论,且没有拿到顺运宝官方
文档确认。以后如果日期范围附近出现"该有的单没同步到",先来这里核对
这条结论是否仍然成立。
---
## 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` 文案)
- [ ] 同一账号多处登录是否互踢
- [ ] 验证码错误、密码错误分别返回什么,能否区分
- [ ] `created` 是 UTC+8 这一条(见 §5.3)只有一次抓包支撑,
没有官方文档确认,也没有跨夏令时/时区配置的验证
`[必须]` 最后两条影响错误提示的准确性:分不清「密码错」和「验证码错」的话,
操作员会一直重输密码。
---
## 10. 相关文档
- 数据落库:[03 数据模型](03-data-model.md)
- 界面:[05 界面规范](05-ui-specification.md) §6 顺运宝数据页
- 安全要求:[06 质量与安全](06-quality-security.md)
- 规格解析:`admin/service/shopee_import.go` 的 `ParseSpec()`