Files
cmautobuy/docs/admin/08-顺运宝接口.md
T
chengmaandClaude Opus 5 2e686b17a4 feat: 顺运宝登录接入验证码自动识别 (#47)
#46 的登录只有手工输验证码一条路,而会话 24 小时就过期——每天第一次
同步都得有人在场,将来也做不了定时同步。

docs/admin/08 §8 当时写死"不引入 OCR 服务",理由是"多一个必须先启动的
东西"。那条判断基于示例脚本里的 http://127.0.0.1:8000/ocr(本机服务)。
用户提供了托管地址后前提不成立,本工单推翻它——文档里改写并保留原文,
让后来人知道这个决定变过、为什么变。

OCR 优先、手工兜底:识别成功直接登录,失败或服务不可达降级到 #46 已有的
手工弹窗,并在弹窗里说明是"已尝试 N 次"还是"服务不可用"。手工路径不删,
外部服务挂了不该让整个同步功能不可用。

识别失败也是 code:200。实测拿无文字图片探测 https://ocr.ilapage.cn/ocr
返回 {"code":200,"message":"Success","data":""}——不是错误码。所以
Recognize 只负责"这次 HTTP 调用有没有问题",空 data 照常返回 (", nil),
业务校验交给调用方;空 data 和长度不对收敛到同一个 len(code) != 4,
一条规则覆盖两种情况。

不合格的验证码不拿去登录:白费一次尝试,且频繁错误登录可能触发风控。
审查时变异测试发现这条没有测试守着——原测试只断言"重新取图了"和
"最终登录成功",禁用长度校验后依然成立。已补 loginRecorder 记录每次
提交到 /am/auth/login 的 code,断言登录只被调用一次且提交的是合格的那个。

每次重试重新取图(同一张图再识别结果一样,且可能已被上次失败的登录作废);
OCR 用独立 HTTP 客户端不带顺运宝 Cookie;验证码图片只在内存里传,不落盘。

OCR 不可达立即降级、不占用重试次数——对着连不上的地址重试 5 次,
操作员要等 50 秒才看到手工输入框,结果注定一样。

测试全部用 httptest,不打真实的 ocr.ilapage.cn 和 shunyunbaoerp.com。

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

20 KiB
Raw Blame History

08 顺运宝 ERP 接口契约

  • 文档状态:从 HAR 抓包还原,未经官方文档核对
  • 来源:raw_data/shunyunbaoerp_*.har(4 份,2026-07-28 抓) 和 raw_data/shunyunbaoerp_single.py(962 行的可运行示例)
  • 基址:https://www.shunyunbaoerp.com

本文档中 [必须] / [建议] / [待定] 的含义见 文档索引。 没有标注的默认是 [必须]。看不懂的词查 术语表。

[必须] 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/** 接口都是这个形状:

{ "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 登录流程

① 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 载荷:

{"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 怎么判断会话还活着

GET /am/user/get?id=<登录响应里的 user.id>

[必须] 必须区分「明确未登录」和「网络故障」:

情况 处理
HTTP 401 / 403 判定未登录,清会话
msg 含「未登录」「登录过期」,或 code=-2 判定未登录,清会话
超时、5xx、响应格式错 抛错,不要判定未登录

理由:网络抖一下就判定登出的话,会触发重新登录,验证码弹个不停, 而且可能把本来有效的会话丢掉。示例脚本这一点做对了,照抄。

[必须] 还要核对返回的 id / username 与缓存的一致—— 不一致说明串号了,同样清会话。


4. 货运单列表

两个接口配合,payload 完全相同:

POST /am/stock/listTotal   → data 是裸整数,总条数
POST /am/stock/list        → data.list 是数组

4.1 请求体

{
  "history": 0,
  "length": 20,          // 每页条数
  "start": 0,            // 偏移
  "pageTotal": 0,
  "pageIndex": 1,        // 从 1 开始
  "store": false,
  "columns": [ ... 72 个列定义 ... ],
  "queries": [ ... 查询条件 ... ]
}

[必须] columns 是要返回哪些列的声明,72 项,每项形如:

{"tableName":"t_stock","colName":"created","fieldName":"created",
 "hasAlias":0,"tableAlias":"t"}

完整清单见示例脚本的 COLUMN_SPECS(第 51–124 行)。 [建议] 直接照搬,不要自己删减——服务端可能依赖这批列做联表。

4.2 两种查询条件

按日期范围(同步用这个) —— 出自 shunyunbaoerp_stock_list.har:

{"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:

{"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 金额合计对不上是正常的

明细单价合计  239.0 + 439.0 = 678.0
amtOrder                      612.0

差 66,应该是优惠。[必须] 不要用「明细合计 == amtOrder」做校验, 会误报。

5.3 created 是 UTC+8,不是 UTC —— 日期范围查询最容易算错的地方

[必须] 实测 raw_data/shunyunbaoerp_stock_query.har:

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. 货运明细

POST /am/stock/detail/listByStock?hist=0
     {"ids": [75104587, ...]}
   → data.list[]

[必须] 一次最多传 100 个 id(示例脚本的分批大小),超了分批。

6.1 一单多商品是嵌套,不是多行

返回的每一行对应一张货运单,商品在嵌套的 details[] 里:

{
  "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 的格式和蝦皮报表完全一致

蝦皮 Excel「商品規格」   黑色,M【建議40-50公斤】
顺运宝 productSpec       白色,L【建議50-60公斤】
                         黑色+白色【純棉兩件裝】 簡約親膚,L【建議52.5-60公斤】

[必须] 直接复用 service.ParseSpec()(#38 实现),不要另写一份解析。 两处各写一份,规则迟早不一致,而解析出的颜色尺码要拿去下单。

[必须] 第二个例子里颜色部分自带 【】,ParseSpec 已覆盖这种情况 (按第一个逗号切开)。

6.4 由此推导出的匹配路径

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 服务。 这条判断在工单 #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 字段直接报错:

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,说明识别错了,不要拿去登录——直接换一张图重试。 拿明知不对的验证码去登录白费一次尝试,而且频繁的错误登录可能触发对方风控。

重试与降级

点同步 → 会话过期
  ├─ 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 换成别人运营的服务前,必须重新评估这一点。

配置

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. 相关文档