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>
This commit is contained in:
chengma
2026-08-09 11:49:13 +08:00
co-authored by Claude Opus 5
parent 7fb137f685
commit 5e426cacf6
22 changed files with 3659 additions and 49 deletions
+49 -5
View File
@@ -94,6 +94,7 @@ SQLite 同一时刻只允许一个写事务,连接放太开会互相抢锁、
| v2 | 新增 `task_claims`(领取历史,见 §8)。 |
| v3 | 把 PDD 采集数据从 `shopee_products` 拆到独立的 `pdd_products`(本文档 §4 描述的最终结构);重建 `shopee_products`,去掉已经搬走的四个字段;重建 `sku_mappings`,主键改成 `(shopee_sku_id, pdd_goods_id)`(§6.1 的理由)。 |
| v4 | `pdd_products` 增加可空的 `shop_name`;老数据保持 `NULL`。 |
| v5 | 顺运宝货运单同步(工单 #46):新增 `syb_session`(会话缓存)、`syb_sync_state`(同步进度)两张表;`syb_orders` 增加可空的 `product_spec`(规格原文)。三条都是新增,v1–v4 一个字节没改。 |
**v3 为什么丢弃旧 `sku_mappings` 数据(见 #20):** 新主键需要 `pdd_option_key`,
这是 Go 的 `service.OptionKey()` 用 `json.Marshal` 算出来的规范化键,SQL 语句
@@ -419,11 +420,12 @@ UPDATE pdd_products
```sql
CREATE TABLE syb_orders (
syb_id TEXT PRIMARY KEY, -- 货运单 ID
order_no TEXT NOT NULL, -- 订单号
syb_id TEXT PRIMARY KEY, -- 货运单**明细行** ID(顺运宝 details[].id)
order_no TEXT NOT NULL, -- 订单号(顺运宝外层 code,不是 orderCode)
title TEXT, -- 商品标题
shopee_goods_id TEXT, -- 蝦皮商品 ID
shopee_sku_id TEXT, -- 蝦皮规格 ID
product_spec TEXT, -- 规格原文,v5 新增,对应 shopee_skus.spec_raw
shopee_goods_id TEXT, -- 蝦皮商品 ID(顺运宝 productId,11 位)
shopee_sku_id TEXT, -- 蝦皮规格 ID,人工/自动匹配的结果
quantity INTEGER NOT NULL CHECK (quantity > 0),
price_twd_cent INTEGER CHECK (price_twd_cent IS NULL OR price_twd_cent >= 0),
image_url TEXT, -- 存 URL,不存图片本身
@@ -450,7 +452,49 @@ CREATE INDEX idx_syb_orders_list ON syb_orders(updated_at DESC, syb_id DESC);
**"编号能对上"和"本地一定查得到"是两回事,别混。**
**"匹配状态"是派生的,不存字段**:`sku_mappings` 里有对应记录就是"已匹配"。
**"匹配状态"是派生的,不存字段**:`shopee_sku_id` 非空就是"已匹配"(本工单未做
`sku_mappings` 联查复用,规格匹配是后续工单的范围)。
`[必须]` **`shopee_sku_id` 顺运宝同步绝不能覆盖**(工单 #46)。它是规格匹配的
结果(人工确认或自动匹配产生),顺运宝那边根本没有这个值(顺运宝只给商品级 `productId`,
不含蝦皮規格ID,见 §5.1 下方接口对照)。`repository.UpsertSybOrder` 的
`ON CONFLICT DO UPDATE SET` 里不出现这一列,新建行时才会写它(此时通常是空值)。
`[必须]` 一行对应顺运宝一张货运单的**一个商品明细**(`details[]` 的一项),
不是一张货运单——一张货运单可以有多个商品,各占一行,`syb_id` 用的是
`details[].id`,不是货运单本身的 `id`。
`[必须]` `price_twd_cent` 一律取 `detail/listByStock` 接口的值(元)自己 ×100
转分、先四舍五入再转整数;不要用 `/am/stock/list` 列表接口的金额字段——
同一响应里不同金额字段的单位不统一,见 [08 顺运宝接口](08-顺运宝接口.md) §5.1。
`[建议]` 收件人姓名/电话/地址不入库,`syb_data` 落库前已剔除。
### 5.1 `syb_session` 顺运宝会话缓存、`syb_sync_state` 同步进度(v5)
```sql
CREATE TABLE syb_session (
username TEXT PRIMARY KEY,
cookies TEXT NOT NULL, -- JSON 数组,Cookie 名/值/路径
expires_at TEXT NOT NULL, -- min(JWT exp, 24h)
updated_at TEXT NOT NULL
);
CREATE TABLE syb_sync_state (
id INTEGER PRIMARY KEY CHECK (id = 1), -- 只允许一行
last_synced_at TEXT,
updated_at TEXT NOT NULL
);
```
- 只存 Cookie,不存登录 JWT——认证完全靠 Cookie,JWT 从不参与后续请求,
见 [08 顺运宝接口](08-顺运宝接口.md) §3.1。
- `syb_sync_state` 只允许一行(`CHECK (id = 1)`),是全局的"上次同步到哪"。
- `[必须]` 增量同步从 `last_synced_at` 对应的**日期当天**重新拉,不是第二天——
`created` 筛选粒度是日期,`last_synced_at` 精确到秒,从第二天拉会漏掉当天
晚些时候创建的单,且不会报错。宁可重复拉(靠 upsert 幂等)也不能漏。
- `[必须]` 只有一次同步**全部成功**才更新 `last_synced_at`;中途失败不更新,
否则下次同步会跳过这段区间,漏掉的单永远补不回来。
## 6. `sku_mappings` 规格映射