Clone
17
SYB-ERP-Interface-Contract
QiuSW edited this page 2026-09-21 16:13:32 +08:00
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.

12 顺云宝(SYB)ERP 接口契约

  • 文档状态:从 HAR 抓包还原,未经官方文档核对
  • 上游来源:本文档移植自 cmautobuy 项目的 docs/admin/08-顺运宝接口.md,随 #48 一并引入
  • 本仓库抓包样本:demo/shunyunbaoerp_*.har
  • 基址:https://www.shunyunbaoerp.com

顺云宝账号、密码、Cookie 和会话不得写入代码、日志、工单和文档。 本文档里的账号、单号、金额全部是脱敏或示例值。 凭据通过环境变量注入,见 §8。

上游文档使用 [必须] / [建议] / [待定] 标注,移植时原样保留。 「上游工单 #NN」指 cmautobuy 项目的工单编号,与本仓库工单无关。


1. 这份文档是怎么来的,可信度如何

全部结论来自抓包和上游的示例脚本,不是官方文档。 上游那份 962 行的示例脚本 未收录进本仓库;下文引用它只是说明某个取值的出处,本仓库不依赖该文件。 凡是只有一个样本支撑的判断, 下面都标了 [待定],实现时要在真实数据上再确认一次。

已核对过的 4 份抓包:

文件 覆盖
demo/shunyunbaoerp_login.har 验证码、登录
demo/shunyunbaoerp_stock_list.har 按日期范围列表查询
demo/shunyunbaoerp_stock_query.har 按单号查询 + 货运明细
demo/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 是数组,data.total 是当前页条数

[必须] 不要把 list.data.total 当成筛选范围总数。实测 HAR 中范围总数为 3846 时,第一页返回 20 行且 list.data.total = 20;范围总数只能以 listTotal.data 为准。

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 两种查询条件

按日期范围(同步用这个) —— 出自 demo/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 表示范围。

按单号(查单条用这个) —— 出自 demo/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。2026-08-29 对稳定历史日(753 张)只读验证:20 条在第 38 页超时;50 条 16 页用时 24.242 秒; 100 条 8 页用时 2 分 34.751 秒;200 条 4 页用时 22.183 秒。三种大页均满足总数、 页长、唯一 ID 和尾页完整性,生产采用保守的 50;明细仍按最多 100 个 ID 一批读取。

[必须] 必须有单次同步的条数上限,超了报错而不是硬拉。 Admin 默认 max_matches = 10000,可以在配置中调整;上限针对整个日期范围的 逐日总数合计,不是每天各算一次。任何一天的列表或明细都不得在容量预检通过前 开始拉取,避免超限后已经产生部分写入。

[必须] 每一页 list.data.total 必须等于该页 list 数组长度。非最后一页 必须返回 length 条,最后一页必须返回预检总数对应的剩余条数。每天翻页结束后 再次调用 listTotal,前后总数必须一致;全部页去重后的货运单 ID 数还必须等于 预检总数。历史日期出现前后总数变化、短页、重复 ID 或唯一 ID 不足时立即失败, 不推进游标。

[必须] listTotal、list 和 detail/listByStock 属于语义只读查询,虽然 使用 POST,也只允许在超时、网络故障、响应读取失败、HTTP 5xx 或响应信封格式异常时 有限重试:单次 HTTP 总超时 60 秒,最多执行 3 次,重试前分别等待 1 秒、2 秒,等待须 响应 context 取消。HTTP 401/403、明确会话失效、业务失败、接口数据完整性错误和本地 校验失败不得重试;登录、验证码、单件码写入和任何回填请求也不得使用该机制。重试 耗尽后返回最后一次错误,外层保留日期、页码和已获取数量上下文,按最终已保存成果将同步标为失败或部分成功。

自 #239(实现提交 c6a962d,已于 2026-09-08 随 d403f3b 部署线上)起,改为按页验证和保存:每页先验证条数、合法 ID、页内/跨页重复,再按冻结店铺快照获取并验证全部明细;外部请求完成后才开启页事务。页内任一入库失败回滚整页,已提交的前页保留,计数在事务提交后累计。

当天和历史日期统一处理:分页/明细失败或最终总数漂移时停止该日期并继续后续日期,不在同次运行中重新扫描当天,以避免重复写入和计数。数据库故障、进度持久化失败、明确会话失效、上下文取消或总任务超时停止整个范围。下一次人工或定时同步重新扫描日期,按 (order_code, detail_id) 幂等覆盖并保留人工确认。跨页重复不去重后冒充完整数据;最终日期完整性校验仍覆盖所有店铺。此流程替代此前 #235 的当天三次整日快照重扫。

重复诊断只记录日期、首次/当前页码和行号、start、pageSize、expectedTotal、已获取唯一数量,不记录真实重复 ID、原始响应或个人数据。

4.4 统一日期范围同步与覆盖游标

GoAuto 同步记录页的立即同步允许管理员和采购员(purchaser)使用(#236),固定覆盖昨天和今天;其他已登录角色仅可查看记录。复用既有同步互斥、日期校验、启用店铺筛选及操作人审计,不授予店铺配置、凭据或定时任务管理权限。权限代码发布并完成启动对账后生效。

页面只有一个同步入口,操作员确认工具条上的开始日和结束日后发起。首次打开页面 固定默认昨天到今天,不因 last_synced_at 更早而自动扩大范围;需要补历史缺口时 由操作员明确选择日期,单次仍不得超过 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 金额合计对不上是正常的

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

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

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

[必须] 实测 demo/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 的角色顺序不是固定的

蝦皮目录 `spec_raw`       黑色,M【建議40-50公斤】
顺运宝 productSpec       白色,L【建議50-60公斤】
                         均碼,黑色

2026-09-04 的真实数据确认:productSpec 至少存在 颜色,尺码 与 尺码,颜色 两种顺序,不能再固定把逗号前当颜色、逗号后当尺码。

[必须] 先按最后一个 ASCII 逗号拆成两段并剥离 【...】 备注。只有两侧 恰好一侧带有明确尺码信号(如均码、字母尺码或明确尺码单位)时,才把该侧判为 尺码、另一侧判为颜色。两侧都像尺码时标记存疑;不得使用模糊颜色词库、AI 或 跨维度候选猜测角色。两侧都没有明确尺码信号时,暂按既有 颜色,尺码 契约兼容, 并继续执行原有复杂分隔符和空值校验。

[必须] 修正规格角色时保留原始 raw_json,不得覆盖人工确认值。已错误写入 虾皮档案的导入规格只能在确认无其他 SYB 明细引用且没有规格映射时移除;人工规格、 映射及历史采购任务保持不变。

[必须] 规格身份继续复用 spec.SpecKey():只折叠空白,不改写用于 PDD 精确点击的原始候选标签。第三方目录脚本提交 spec_raw 和明确解析结果;角色或 规格比对不明确时保持存疑,不得跨颜色/尺码猜测。

6.4 由此推导出的匹配路径

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 侧不用跟着调。

7.1 档口入库码逐件写入(上游工单 #234、#250、#278)

本节已由 GoAuto #119~#122 实现。匹配阶段保持纯只读;只有采购员在 Admin 回写确认弹窗二次确认后,后台回写执行器才可调用写接口。#37 的采购物流回填仍是独立范围。

档口入库码最终写入货运明细的 innerExpCode(页面名称“快递单号”)。接口来自 已验证的上游流程和 HAR 响应样本:

GET /am/stock/detail/deleteInnerCode?detailId={detailID}
POST /am/stock/detail/createDetail
POST /am/stock/detail/updateDetailCode?t=0&id={stockID}&detailId={detailID}&code={单件innerCode}

[必须] t 固定为 0。顺运宝把该参数绑成 Java Integer(上限 2147483647)。 #250 改成当前毫秒时间戳后,2026-08-19 全部回写被拒: Can not parse the parameter "1787108752485" to Integer value。不得再传毫秒, 也不得改成 Unix 秒——秒目前能进 Integer,2038 年仍会溢出。#234 的 t=0 已通过参数绑定;#250 保留的 POST 方法不要改回 GET。

createDetail 使用 JSON 请求体:id=null、稳定占位标题、productSpec=null、 productQty=1、productPrice=0、stockId;成功响应的 data 是新 detailId。

  • 回写前必须同时核对目标码数量、Excel 合并源行数和原商品 productQty;任一不一致时 零写入。一个远端明细只写一个单件码,原明细为空时承载第一个缺失码,其余缺失码创建 零价占位明细。
  • 同一货运单任意明细已有目标码时直接复用,不删除、不搬移。稳定占位标题可用于识别 “已创建但检查点未保存”的明细,防止崩溃后重复创建。
  • 规划时的旧值为空时不调用删除;旧值非空且仍与规划快照一致时,只有删除明确成功才继续。
  • 删除和写入请求都只允许发送一次,不使用自动重试。超时、5xx、响应读取失败或成功响应 无法解析时,结果可能已经在远端生效,必须标为“需核对”。
  • 每件动作前后把检查点写入本地 syb_inner_code_checkpoint;匹配快照保存在 syb_inner_code_plan.remote_items_json。每个单件码写入后重新读取整张货运单, 只有它唯一出现在预期明细中才继续。全部目标码确认后才标为已回写。
  • “重新核对”只调用 listByStock,绝不能再次调用上述两个写接口。
  • 页面提交数量不限制为 20 条。所选记录先在 MySQL 事务中进入 queued,HTTP 请求 随即返回;后台取得数据库全局租约后逐条执行上述远端门禁。Admin 重启不会自动继续 未开始的真实远端写入:queued 恢复为可回写,applying 转为需核对。

8. 实现时的固定约束

这几条不是抓包结论,是本项目的决定,写在这里避免每次重新讨论:

以下「店铺准入」已由 GoAuto #49 采纳并实现;店铺规范化还包括全角/半角统一和忽略大小写。

[必须] 同步先校验原始全量,再做店铺准入。 顺序固定为:按日期查询原始总数 并执行单次容量熔断 → 逐页核对页长和唯一 ID,整日结束复核总数 → 按 shopName 去除首尾空白后与启用店铺精确匹配 → 只为接受的货运单请求明细和入库。 店铺过滤只决定明细获取和入库,不能减少原始列表完整性校验范围;已保存不代表整日完整。

[必须] 店铺准入之后再按 variationSku 过滤商品(#269)。 顺序固定为:店铺准入 → 结构过滤 → 关键词过滤 → 入库。两类规则都存在 syb_product_filter,由 kind 区分:

  • kind=char:- 和 # 两条,命中任意一条即跳过(OR,不是 AND)。这两个字符是 「档口-供应商#货号」编码格式的判据。2026-09-11 核验线上 13010 行明细:含 # 8498 行、 含 - 6392 行、两者都含 6386 行、任一 8504 行(65%)。用 AND 只命中 6386 行,会漏掉 DD#004、300斤牛奶絲圓領#A057 这类只含 # 的 2118 行。
  • kind=keyword:关键词清单,匹配 variationSku,不匹配 productTitle。同一次核验 中六条初始关键词在 productTitle 上命中为 0,且全部已被结构过滤覆盖,净增为 0;保留它 是为档口改用不含 # 的编码时兜底。

[必须] 空 variationSku 不过滤。核验时 2724 行(21%)为空,分布在正常启用店铺且持续 产生,属于尚未填写供应商编码的正常商品,不是档口货。

[必须] 两类命中数分别统计并分别回写到每条规则自己的 last_hit_count,不得合并成单一 「过滤总数」,也不得按 kind 写同一个汇总值。前者会使结构判据失效不可观测(关键词净增为 0 时无法区分「没漏网」和「规则没生效」);后者会让停用确认对 # 和 - 提示同一个数字。

[必须] kind=char 不可新增、不可删除,只能停用,且停用需要二次确认并记录操作人与时间。 判据本身需要变更(例如档口改用 / 或 @)属于范围变化,应另建工单评估。

[必须] 同步开始时只读取一次启用店铺,整次运行使用同一个快照。列表允许但明细 响应中的 shopName 变为空或非允许店铺时再次拦截。没有启用店铺时在会话/OCR/ 验证码等任何顺运宝请求之前停止,并且不推进覆盖游标。该过滤只影响后续入库, 不清理历史货运单。

[必须] 会话缓存存 GoAuto 自己的 MySQL,不引入 Redis。 上游示例脚本用 Redis 是因为 它是反复启动的一次性脚本,进程间要传会话;GoAuto 服务端是常驻进程,没有这个需求, 持久化只为重启后免登录,复用现有数据库即可。表见 syb_session。

[决定已变更] 不引入 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 自动识别, 失败或服务不可达时降级到原有的手工输入弹窗(那条兜底路径没有变), 不是"失败了照样要人工"变成了"失败了才要人工"。详见下面「验证码自动识别」一节。

凭据与配置

[必须] 凭据只通过环境变量注入,不进任何被 Git 跟踪的文件。 GoAuto 已有的做法见 server/config/extend.go 的 ApplyEnvironment(): 非机密项写在 server/config/settings.yml 的 extend.syb 下,机密项走环境变量。

项 来源 说明
GOAUTO_SYB_USERNAME 环境变量 顺云宝账号
GOAUTO_SYB_PASSWORD 环境变量 顺云宝密码
extend.syb.base_url settings.yml 默认 https://www.shunyunbaoerp.com
extend.syb.page_size settings.yml 列表每页条数;2026-08-29 只读实验验证 50/100/200 完整,生产默认采用保守值 50
extend.syb.max_matches settings.yml 单次同步货运单数上限,超过即停止
extend.syb.ocr_url settings.yml 验证码识别服务;留空 = 禁用,只走手工输入
extend.syb.ocr_max_attempts settings.yml OCR 重试次数,默认 5

[必须] 密码在任何日志里都要打码。 日志可能被贴进工单排查问题。

[必须] 用环境变量而不是 YAML,顺带避开了上游踩过的坑:YAML 里纯数字密码 不加引号会被解析成整数(0012345 → 12345,前导零丢失),反序列化到 string 字段直接报错。环境变量永远是字符串,没有这个问题。


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 秒)。

[必须] 验证码图片全程在内存里传字节,不写文件。 参考实现 上游示例脚本 把图片存成 captcha.jpg 是命令行脚本 的做法,Admin 是常驻进程,写文件只会在 data/ 里堆垃圾。

[必须] 验证码图片会被发送到 ocr_url 配置的外部服务。 当前 https://ocr.ilapage.cn/ocr 是用户自己的服务,不算交给第三方; 把 ocr_url 换成别人运营的服务前,必须重新评估这一点。

[必须] GoAuto 的永久规则原本禁止一切 OCR/VLM。该规则是为 Android Agent 端采集 写的,#48 把它按端重新划定:Agent 端维持禁止,服务端 SYB 登录验证码是唯一例外。 不得以本节为由把 OCR 引入 Agent 端或任何 PDD 相关流程。 见 Business-Rules-and-Glossary 的「自动化边界」。

配置

# server/config/settings.yml
settings:
  application:
    extend:
      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. 相关文档

SYB 逐页保存与部分成功(#239)

实现绑定 c6a962d;2026-09-08 已随 d403f3b 部署线上,未手动触发真实同步验收;#240 上游尾页超时尚未修复。每页完整明细在外部请求结束后按页事务保存;页回滚不累计明细/新增/覆盖数,已提交页保留。日期局部读取失败继续下一日期,全局数据库/进度/会话/取消故障停止。当天漂移不在一次运行内重扫;后续运行重新扫描并幂等补齐。

同步状态增加 partial_success(部分成功,15 字符,复用现有 varchar(16),无需迁移)。有错误且 created+updated>0 为部分成功;有错误无已提交明细为 failed;完整且无错误为 succeeded(包括无符合店铺的数据)。中断仍为 interrupted,不把中断追认为成功。所有终态沿用活动槽释放规则。

orderCount 是已验证页的原始列表读取数量;detailCount、created、updated 为已提交明细及其新增/覆盖数量;daysProcessed 是完整通过的日期数,不是已尝试日期数。失败日期/页码/阶段写入现有脱敏限长 errorMessage。部分成功不刷新店铺的完整同步统计。

Web 唯一展示位置为“采集采购 → SYB 同步记录”:列表状态、状态筛选及详情支持部分成功,详情保留已保存数量、错误原因与重新同步补齐提示。定时任务日志只表示异步任务受理,不等于最终业务同步成功。

PDD 采购单号写入(#305)

实现绑定 e89de1a(2026-09-18,未部署)。协议依据用户本地 update_syb_pdd_order_number.har 单次样本;HAR/真实标识/Cookie不进入源码、Wiki或工单。本节不宣称远端幂等或原子比较写入。

POST /am/stock/detail/updateDetailPurchaseCode,无JSON body;query:id=<stock.id>、detailId=<details[].id>、code=<PDD单号>、type=pdd、created=""、cost=0。ID必须为正整数,单号非空、无首尾空白/控制换行、最多100字符。cost是抓包确认的固定参数,不是实际金额;不得传Agent实付价格。

样本成功信封 HTTP200、status=true;随后 listByStock?hist=0 对应明细 purchaseCode 等于提交单号、purchasePlatform=pdd、purchaseStatus=1,purchaseTime由SYB生成。更新存在采购状态/时间副作用。

客户端每次处理最多一次写请求,并拒绝HTTP重定向重发;超时/连接中断/5xx/损坏响应作为结果未知。无论写响应成功或未知,都须按 stockId + detailId 唯一回读目标;相同单号+pdd才确认为成功,不同已有单号或其他非空平台记冲突且不覆盖。写前为空才写;目标缺失/重复/字段类型异常不得猜测选择。未知且未回读确认时持久化unknown,人工补偿前再次回读,不使用自动写重试。

GoAuto自身通过持久租约串行,但SYB没有CAS证据,无法原子隔离系统外客户端;不能保证任意迟到请求已终止。真实接口写入需另行明确授权;当前验证仅fake/httptest。