# 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":""}} ``` `[必须]` 三步必须用**同一个 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 是数组,data.total 是当前页条数 ``` `[必须]` 不要把 `list.data.total` 当成筛选范围总数。实测 HAR 中范围总数为 3846 时,第一页返回 20 行且 `list.data.total = 20`;范围总数只能以 `listTotal.data` 为准。 ### 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 = 20` 翻页调用 `list`。明细仍按 最多 100 个货运单 ID 一批读取。 `[必须]` **必须有单次同步的条数上限**,超了报错而不是硬拉。 Admin 默认 `max_matches = 10000`,可以在配置中调整;上限针对整个日期范围的 逐日总数合计,不是每天各算一次。任何一天的列表或明细都不得在容量预检通过前 开始拉取,避免超限后已经产生部分写入。 `[必须]` 每一页 `list.data.total` 必须等于该页 `list` 数组长度。非最后一页 必须返回 `length` 条,最后一页必须返回预检总数对应的剩余条数。每天翻页结束后 再次调用 `listTotal`,前后总数必须一致;全部页去重后的货运单 ID 数还必须等于 预检总数。前后总数变化、短页、重复 ID 或唯一 ID 不足都视为本次同步失败, 不推进游标。 ### 4.4 统一日期范围同步与覆盖游标 页面只有一个同步入口,操作员确认工具条上的开始日和结束日后发起。通常默认最近 3 天;如果 `last_synced_at` 更早,默认范围从覆盖游标当天开始。缺口超过 31 天时 只选择最早一段 31 天,本段成功后下一次继续显示后一段。 `[必须]` 首次成功同步以所选结束日建立覆盖游标。已有游标时,只有日期范围从 游标当天或更早开始、并且结束日在游标之后,全部成功后才推进游标。局部历史补拉 或跳过缺口的范围只 upsert 数据,不动游标。 例如覆盖游标为 `2026-08-01`,同步 `2026-08-01 ~ 2026-08-09` 可以推进到 `2026-08-09`;只同步 `2026-08-07 ~ 2026-08-08` 不能推进,因为中间有缺口。 `[必须]` 日期格式固定 `YYYY-MM-DD`,按 UTC+8 解释;两端必须同时填写, 开始不得晚于结束,结束不得晚于 UTC+8 下的今天;闭区间最多 31 天。中途失败 或超过 `max_matches` 时不推进游标。 `[必须]` 每批明细响应必须与请求的货运单 ID 一一对应。缺失、重复、出现未请求 ID,或某张货运单返回空商品明细,都视为不完整并停止同步;已经写入的幂等数据 可以保留,但只有所有日期全部成功才推进游标。 `[必须]` 登录和验证码只是同步前置步骤。日期范围经过自动 OCR 降级、手工 输入验证码和 303 跳转时必须原样保留。 --- ## 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 蝦皮目录 `spec_raw` 黑色,M【建議40-50公斤】 顺运宝 productSpec 白色,L【建議50-60公斤】 黑色+白色【純棉兩件裝】 簡約親膚,L【建議52.5-60公斤】 ``` `[必须]` 规格身份统一复用 `spec.SpecKey()`:只折叠空白,不猜颜色、尺码或建议。 第三方目录脚本提交 `spec_raw` 和明确的解析结果;顺运宝匹配不到时交给人工确认。 ### 6.4 由此推导出的匹配路径 ```text productId ──→ shopee_products.goods_id 商品级,直接相等 productSpec ──→ SpecKey() ──→ 稳定规格身份键 ──→ 在该商品目录和人工映射中查找 ``` `[必须]` 比对不上时**不要猜**,标成待人工匹配。蝦皮报表只含有销售成绩的 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. 实现时的固定约束 这几条不是抓包结论,是本项目的决定,写在这里避免每次重新讨论: `[必须]` **会话缓存存 Admin 的 MySQL,不引入 Redis。** 示例脚本用 Redis 是因为 它是反复启动的一次性脚本,进程间要传会话;Admin 是常驻进程,没有这个需求, 持久化只为重启后免登录,继续复用现有数据库即可。 `[决定已变更]` ~~不引入 OCR 服务。~~ 这条判断在工单 #47 里被推翻了, 原文和推翻理由都留在这里,方便后来人知道这个决定变过、为什么变: > 原判断(工单 #46):会话 24 小时,一天登录一次。为省一次手工输验证码 > 而依赖 `127.0.0.1:8000` 不划算——多一个必须先启动的东西,而且 OCR > 会失败(示例脚本自己写了 5 次重试),失败了照样要人工。界面上显示 > 验证码图片、操作员输一次即可。 `[必须]` **这条判断的前提是"本机服务 `127.0.0.1:8000`",托管服务不适用。** 工单 #47 里用户提供了托管地址 `https://ocr.ilapage.cn/ocr`:没有要启动的 东西,就是一次 HTTP 调用,"多一个必须先启动的东西"这条理由不成立了。 而"每天第一次同步都要人在场"这个代价是实打实的——会话 24 小时过期, 意味着做不了无人值守的定时同步。于是工单 #47 引入了 OCR 自动识别, 失败或服务不可达时**降级**到原有的手工输入弹窗(那条兜底路径没有变), 不是"失败了照样要人工"变成了"失败了才要人工"。详见下面「验证码自动识别」一节。 `[必须]` **凭据放 `admin/config.yaml`**,已在 `.gitignore` 里。 仓库里提供 `admin/config.example.yaml` 作为模板(不含真实凭据)。 `[必须]` **密码在任何日志里都要打码。** 日志可能被贴进工单排查问题。 `[必须]` YAML 里**密码要加引号**。纯数字密码不加引号会被 YAML 解析成整数, 反序列化到 `string` 字段直接报错: ```yaml password: 0012345 # ✗ 解析成整数 12345,前导 0 也丢了 password: "0012345" # ✓ ``` `[建议]` 依赖用 `github.com/goccy/go-yaml`,它已经在依赖树里(indirect), 不会引入新的传递依赖。 --- ## 8.1 验证码自动识别(工单 #47) ### 响应格式(已实测) ``` POST https://ocr.ilapage.cn/ocr Content-Type: multipart/form-data,字段名 file → 200 {"code":200,"message":"Success","data":"kycv"} ``` 实测耗时约 1.4 秒。 `[必须]` **识别失败也是 `code:200`,这是最容易写错的地方。** 实测用一张无文字的图片探测,返回的是 `{"code":200,"message":"Success","data":""}`—— 不是错误码,是 `code:200` 加空 `data`。判断这一次调用真正"识别出了点什么", 必须同时满足:HTTP 200、`code == 200`、**`data` 非空**。只看 `code` 会把 "没识别出来"当成功,拿空字符串去登录。 `[必须]` 顺运宝验证码固定 **4 位字母数字**(08 §3.2 实测)。识别结果过滤 空格标点后长度不是 4,说明识别错了,**不要拿去登录**——直接换一张图重试。 拿明知不对的验证码去登录白费一次尝试,而且频繁的错误登录可能触发对方风控。 ### 重试与降级 ```text 点同步 → 会话过期 ├─ ocr_url 已配置 │ └─ 循环 ocr_max_attempts 次:取新验证码图 → OCR 识别 → │ 校验(非空 && 4位字母数字) → 登录 │ ├─ 登录成功 ─────────────────→ 直接同步,无人值守 │ └─ 次数用完仍失败 ────────────┐ └─ ocr_url 未配置 / 请求本身失败 ────────┴─→ 弹手工输入框(#46 已有的兜底路径) ``` `[必须]` **OCR 不可达要降级,不是报错。** 外部服务挂了不该让整个同步功能 不可用——手工路径一直在,走它就是了。 `[必须]` 每次重试都要**重新取一张验证码图**。同一张图再识别一次结果一样, 纯属浪费;而且验证码可能已经被上一次失败的登录作废。 `[必须]` OCR 请求本身失败(连不上、超时、返回非法 JSON、`code != 200`)判定为 "服务不可用",**立即降级,不占用重试次数**——重试对"服务本身连不上"这种情况 没有意义。只有"HTTP 调用成功但识别结果不合格(空/长度不对)"才占用一次重试。 `[必须]` 调 OCR **不带顺运宝的 Cookie**,用独立的 `http.Client`(独立的 Cookie Jar、独立的超时),避免把顺运宝会话泄漏给另一个服务;也不共用 顺运宝请求的超时,OCR 慢不该拖垮整个登录流程(`[建议]` 10 秒)。 `[必须]` **验证码图片全程在内存里传字节,不写文件。** 参考实现 `raw_data/shunyunbaoerp_single.py` 把图片存成 `captcha.jpg` 是命令行脚本 的做法,Admin 是常驻进程,写文件只会在 `data/` 里堆垃圾。 `[必须]` **验证码图片会被发送到 `ocr_url` 配置的外部服务。** 当前 `https://ocr.ilapage.cn/ocr` 是用户自己的服务,不算交给第三方; 把 `ocr_url` 换成别人运营的服务前,必须重新评估这一点。 ### 配置 ```yaml syb: # 验证码自动识别服务地址。留空则只用手工输入弹窗,不报错。 ocr_url: https://ocr.ilapage.cn/ocr ocr_max_attempts: 5 ``` `[必须]` `ocr_url` 留空 = 禁用,直接走手工输入弹窗,**不报错**。 --- ## 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/spec/speckey.go` 的 `SpecKey()`