Files
cmautobuy/admin/syb/client.go
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

590 lines
19 KiB
Go
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.
// Package syb 是顺运宝 ERP 的 HTTP 客户端:验证码、登录、货运单列表、
// 货运单明细。
//
// 接口契约来自 docs/admin/08-顺运宝接口.md(从抓包还原),登录/查询流程
// 照 raw_data/shunyunbaoerp_single.py 抄,但**存储换 SQLite、不用 Redis、
// 不用 OCR**——那两样是那个一次性脚本的需要,Admin 是常驻进程,见 08 §8。
//
// `[必须]` 本包只负责"怎么跟顺运宝的 HTTP 接口打交道",不碰数据库、
// 不认识 *gin.Context。编排(算日期范围、字段映射、落库)在
// admin/service/syb.go。
package syb
import (
"bytes"
"context"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"net/http/cookiejar"
"net/url"
"strconv"
"strings"
"time"
)
// ErrSessionInvalid 表示服务端**明确**判定当前会话未登录或已过期
// (docs/admin/08-顺运宝接口.md §3.5)。调用方看到这个错误应该清掉
// 本地缓存的会话、提示操作员重新登录。
//
// `[必须]` 只有能明确判定"未登录"的情况才包成这个:HTTP 401/403,
// 或响应体 status=false 且 msg 含"未登录"/"登录过期",或 code 是 -2。
// **超时、5xx、JSON 格式错误一律是别的错误类型**,不能包成这个——
// 网络抖一下就判定登出会触发不必要的重新登录、验证码弹个不停,
// 还可能把本来有效的会话丢掉,见 §3.5 的理由和工单 #46。
var ErrSessionInvalid = errors.New("顺运宝会话未登录或已过期")
// Client 是一个顺运宝 ERP 会话:验证码、登录、货运单查询共用同一个
// http.Client(同一个 Cookie Jar)。
//
// `[必须]` 08 §3.2:验证码和登录必须用**同一个** Cookie Jar,
// 换客户端拿到的验证码就对不上——所以本包不提供"每次请求新建一个
// Client"的用法,调用方要在整个"取验证码 → 登录"流程里复用同一个实例。
type Client struct {
baseURL string
http *http.Client
jar *cookiejar.Jar
}
// New 创建一个新的顺运宝客户端,带一个空的 Cookie Jar。
func New(baseURL string) (*Client, error) {
baseURL = strings.TrimRight(strings.TrimSpace(baseURL), "/")
if baseURL == "" {
return nil, fmt.Errorf("顺运宝 base_url 不能为空")
}
jar, err := cookiejar.New(nil)
if err != nil {
return nil, fmt.Errorf("创建顺运宝客户端的 Cookie Jar 失败: %w", err)
}
return &Client{
baseURL: baseURL,
jar: jar,
http: &http.Client{
Jar: jar,
// 5 秒连接 + 30 秒读取是示例脚本用的值(见 raw_data/shunyunbaoerp_single.py),
// 这里简化成一个总超时,量级一致。
Timeout: 30 * time.Second,
},
}, nil
}
// ---------- 会话持久化:Cookie 导入/导出 ----------
// cookieDTO 是缓存进 syb_session.cookies 的单个 Cookie 的 JSON 形状。
type cookieDTO struct {
Name string `json:"name"`
Value string `json:"value"`
Path string `json:"path,omitempty"`
}
// ExportCookiesJSON 把当前 Cookie Jar 里属于 base_url 的 Cookie
// 导出成 JSON 数组文本,供 repository.SaveSybSession 存进去。
func (c *Client) ExportCookiesJSON() (string, error) {
u, err := url.Parse(c.baseURL)
if err != nil {
return "", fmt.Errorf("解析 base_url 失败: %w", err)
}
cookies := c.jar.Cookies(u)
dtos := make([]cookieDTO, 0, len(cookies))
for _, ck := range cookies {
dtos = append(dtos, cookieDTO{Name: ck.Name, Value: ck.Value, Path: ck.Path})
}
b, err := json.Marshal(dtos)
if err != nil {
return "", fmt.Errorf("序列化 Cookie 失败: %w", err)
}
return string(b), nil
}
// ImportCookiesJSON 把缓存的 Cookie JSON 恢复进当前 Cookie Jar,
// 恢复登录会话时用(重启 Admin 后免登录)。
func (c *Client) ImportCookiesJSON(cookiesJSON string) error {
var dtos []cookieDTO
if err := json.Unmarshal([]byte(cookiesJSON), &dtos); err != nil {
return fmt.Errorf("解析缓存的顺运宝 Cookie 失败: %w", err)
}
u, err := url.Parse(c.baseURL)
if err != nil {
return fmt.Errorf("解析 base_url 失败: %w", err)
}
cookies := make([]*http.Cookie, 0, len(dtos))
for _, d := range dtos {
if d.Name == "" {
continue
}
path := d.Path
if path == "" {
path = "/"
}
cookies = append(cookies, &http.Cookie{Name: d.Name, Value: d.Value, Path: path})
}
c.jar.SetCookies(u, cookies)
return nil
}
// ---------- 统一响应信封 ----------
// envelope 是 /am/** 接口统一的响应形状,见 08 §2:
//
// { "status": true, "msg": "获取成功", "data": <任意>, "code": null }
//
// `[必须]` 判断成功只看 status === true,不看 HTTP 状态码——服务端
// 业务失败时也可能返回 200。data 可能是对象/数组/裸整数,所以用
// json.RawMessage 延后解析,各接口自己按预期形状再反序列化一次。
type envelope struct {
Status bool `json:"status"`
Msg string `json:"msg"`
Data json.RawMessage `json:"data"`
Code json.RawMessage `json:"code"`
}
// do 发一个请求并取出统一响应信封里的 data,同时按 §3.5 的规则
// 把"明确未登录"和"别的错误"分开。
//
// `[必须]` 这一个函数是本包**唯一**发起 HTTP 请求、判断错误类型的地方;
// 登录、会话校验、货运单列表/明细全部走它,好处是"网络故障不能判定
// 未登录"这条规则只需要写一遍、测一遍,不会在多个接口各写一份、
// 早晚有一处漏判。
func (c *Client) do(ctx context.Context, method, path string, query url.Values, body any) (json.RawMessage, error) {
fullURL := c.baseURL + path
if len(query) > 0 {
fullURL += "?" + query.Encode()
}
var bodyReader io.Reader
if body != nil {
b, err := json.Marshal(body)
if err != nil {
return nil, fmt.Errorf("构造请求体失败: %w", err)
}
bodyReader = bytes.NewReader(b)
}
req, err := http.NewRequestWithContext(ctx, method, fullURL, bodyReader)
if err != nil {
return nil, fmt.Errorf("构造顺运宝请求 %s 失败: %w", path, err)
}
if bodyReader != nil {
req.Header.Set("Content-Type", "application/json")
}
req.Header.Set("Accept", "application/json, text/plain, */*")
req.Header.Set("X-Requested-With", "XMLHttpRequest")
resp, err := c.http.Do(req)
if err != nil {
// 网络故障(超时、连不上、DNS 失败……)——`[必须]` 不能当成"未登录",
// 见 ErrSessionInvalid 的注释和 08 §3.5。
return nil, fmt.Errorf("请求顺运宝接口 %s 失败(网络问题,不代表未登录): %w", path, err)
}
defer resp.Body.Close()
raw, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("读取顺运宝接口 %s 响应失败: %w", path, err)
}
if resp.StatusCode == http.StatusUnauthorized || resp.StatusCode == http.StatusForbidden {
return nil, fmt.Errorf("顺运宝接口 %s 返回 %d: %w", path, resp.StatusCode, ErrSessionInvalid)
}
if resp.StatusCode >= http.StatusInternalServerError {
return nil, fmt.Errorf("顺运宝接口 %s 返回 %d(服务端故障,不代表未登录)", path, resp.StatusCode)
}
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("顺运宝接口 %s 返回意外状态码 %d", path, resp.StatusCode)
}
var env envelope
if err := json.Unmarshal(raw, &env); err != nil {
return nil, fmt.Errorf("顺运宝接口 %s 响应不是合法 JSON(格式错误,不代表未登录): %w", path, err)
}
if !env.Status {
if isSessionInvalidMessage(env.Msg, env.Code) {
return nil, fmt.Errorf("顺运宝接口 %s: %s: %w", path, env.Msg, ErrSessionInvalid)
}
return nil, fmt.Errorf("顺运宝接口 %s 业务失败: msg=%s code=%s",
path, orDefault(env.Msg, "(无)"), string(env.Code))
}
return env.Data, nil
}
// isSessionInvalidMessage 判断业务失败信息是不是"明确未登录",
// 规则见 08 §3.5:msg 含"未登录"/"登录过期",或 code 是 -2
// (数字或字符串两种写法都算,服务端返回哪种没有实测确认过)。
func isSessionInvalidMessage(msg string, code json.RawMessage) bool {
if strings.Contains(msg, "未登录") || strings.Contains(msg, "登录过期") {
return true
}
c := strings.TrimSpace(string(code))
return c == "-2" || c == `"-2"`
}
func orDefault(s, fallback string) string {
if s == "" {
return fallback
}
return s
}
// ---------- 验证码 ----------
// Captcha 是一张验证码图片。
type Captcha struct {
Image []byte
ContentType string
}
// FetchCaptcha 取一张新的验证码图片:GET /api/p/code1?<毫秒时间戳>。
//
// `[必须]` 时间戳参数每次都要换,是为了绕开浏览器/中间层缓存,见 08 §3.2。
func (c *Client) FetchCaptcha(ctx context.Context) (*Captcha, error) {
u := fmt.Sprintf("%s/api/p/code1?%d", c.baseURL, time.Now().UnixMilli())
req, err := http.NewRequestWithContext(ctx, http.MethodGet, u, nil)
if err != nil {
return nil, fmt.Errorf("构造验证码请求失败: %w", err)
}
resp, err := c.http.Do(req)
if err != nil {
return nil, fmt.Errorf("获取顺运宝验证码失败(网络问题): %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("获取顺运宝验证码失败,HTTP 状态码 %d", resp.StatusCode)
}
ct := resp.Header.Get("Content-Type")
if !strings.HasPrefix(ct, "image/") {
return nil, fmt.Errorf("验证码接口没有返回图片,Content-Type=%q", ct)
}
data, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("读取验证码图片失败: %w", err)
}
return &Captcha{Image: data, ContentType: ct}, nil
}
// ---------- 登录 ----------
// LoginUser 是登录响应里的用户信息。
type LoginUser struct {
ID int64
Username string
}
// LoginResult 是登录成功后要缓存的信息。
type LoginResult struct {
User LoginUser
// ExpiresAt 是 min(JWT exp, 从现在起 24 小时)——08 §3.3/§3.4:
// 会话有效期正好 24 小时,缓存不能活得比会话长。
ExpiresAt time.Time
}
// Login 提交用户名、密码、验证码登录。
//
// `[必须]` 密码只出现在请求体(走 HTTPS)。本函数、以及它调用的 do(),
// 产生的任何错误信息都不包含密码——错误信息只带 path/msg/code,
// 不回显请求体,防止密码进日志(admin/AGENTS.md「日志不得出现密码」)。
func (c *Client) Login(ctx context.Context, username, password, code string) (*LoginResult, error) {
if username == "" || password == "" || code == "" {
return nil, fmt.Errorf("用户名、密码、验证码均不能为空")
}
data, err := c.do(ctx, http.MethodPost, "/am/auth/login", nil, map[string]string{
"username": username,
"password": password,
"code": code,
})
if err != nil {
return nil, err
}
var payload struct {
User struct {
ID int64 `json:"id"`
Username string `json:"username"`
} `json:"user"`
Token string `json:"token"`
}
if err := json.Unmarshal(data, &payload); err != nil {
return nil, fmt.Errorf("登录响应格式错误: %w", err)
}
if payload.User.ID == 0 || payload.User.Username == "" {
return nil, fmt.Errorf("登录响应中缺少 user 信息")
}
// `[必须]` 缓存有效期取 min(JWT 剩余, 24h)——08 §3.3。
// 解析不出 JWT(或它不含 exp)时退化成"从现在起 24 小时",
// 不因为解析失败就直接报错——登录本身已经成功了。
expiresAt := time.Now().Add(24 * time.Hour)
if exp, ok := jwtExpiry(payload.Token); ok && exp.Before(expiresAt) {
expiresAt = exp
}
return &LoginResult{
User: LoginUser{ID: payload.User.ID, Username: payload.User.Username},
ExpiresAt: expiresAt,
}, nil
}
// jwtExpiry 从 JWT 的 payload 段解析 exp(Unix 秒)。
// 解析不了返回 (零值, false),不 panic、不报错——调用方负责兜底。
func jwtExpiry(token string) (time.Time, bool) {
parts := strings.Split(token, ".")
if len(parts) != 3 {
return time.Time{}, false
}
payload := parts[1]
if m := len(payload) % 4; m != 0 {
payload += strings.Repeat("=", 4-m)
}
raw, err := base64.URLEncoding.DecodeString(payload)
if err != nil {
return time.Time{}, false
}
var claims struct {
Exp int64 `json:"exp"`
}
if err := json.Unmarshal(raw, &claims); err != nil || claims.Exp == 0 {
return time.Time{}, false
}
return time.Unix(claims.Exp, 0), true
}
// CheckSession 用 GET /am/user/get?id=<userID> 校验当前 Cookie 代表的
// 会话是否仍然有效,并核对返回的 id/username 与期望值一致(08 §3.5:
// 不一致说明串号了,同样按未登录处理)。
//
// 返回 nil 表示会话有效;返回 ErrSessionInvalid(可用 errors.Is 判断)
// 表示明确未登录;返回其它错误表示网络/格式问题,**不代表未登录**。
func (c *Client) CheckSession(ctx context.Context, userID int64, username string) error {
q := url.Values{"id": {strconv.FormatInt(userID, 10)}}
data, err := c.do(ctx, http.MethodGet, "/am/user/get", q, nil)
if err != nil {
return err
}
var got struct {
ID any `json:"id"`
Username string `json:"username"`
}
if err := json.Unmarshal(data, &got); err != nil {
return fmt.Errorf("会话校验响应格式错误: %w", err)
}
if got.Username == "" {
return fmt.Errorf("会话校验响应缺少 id/username")
}
gotID := fmt.Sprintf("%v", got.ID)
if gotID != strconv.FormatInt(userID, 10) || got.Username != username {
return ErrSessionInvalid
}
return nil
}
// ---------- 货运单列表 ----------
// listPayload 组装 /am/stock/listTotal、/am/stock/list 共用的请求体,
// 见 08 §4.1/§4.2:按日期范围查询(同步用这个),dvalue 是
// "起始日期,结束日期",YYYY-MM-DD,逗号分隔。
func listPayload(dateFrom, dateTo string, start, pageIndex, pageSize int) map[string]any {
return map[string]any{
"history": 0,
"length": pageSize,
"start": start,
"pageTotal": 0,
"pageIndex": pageIndex,
"store": false,
"columns": columnsPayload(),
"queries": []map[string]any{
{
"dvalue": dateFrom + "," + dateTo,
"tableName": "t_stock",
"colName": "created",
"op": 0,
"type": 3,
"tableAlias": "t",
"optType": 0,
},
},
}
}
// ListTotal 查某个日期范围内的货运单总数:POST /am/stock/listTotal。
// `[必须]` data 是裸整数,不是对象,见 08 §2。
func (c *Client) ListTotal(ctx context.Context, dateFrom, dateTo string, pageSize int) (int, error) {
data, err := c.do(ctx, http.MethodPost, "/am/stock/listTotal", nil,
listPayload(dateFrom, dateTo, 0, 1, pageSize))
if err != nil {
return 0, err
}
var total int
if err := json.Unmarshal(data, &total); err != nil {
return 0, fmt.Errorf("listTotal 返回的总数格式错误: %s", string(data))
}
return total, nil
}
// StockRow 是货运单列表里的一行。Raw 保留完整原始字段,
// 供 service 层落库 syb_data 时和明细合并。
type StockRow struct {
ID int64
Code string
Raw map[string]any
}
// ListPage 按日期范围翻一页货运单列表:POST /am/stock/list。
func (c *Client) ListPage(ctx context.Context, dateFrom, dateTo string, start, pageIndex, pageSize int) ([]StockRow, error) {
data, err := c.do(ctx, http.MethodPost, "/am/stock/list", nil,
listPayload(dateFrom, dateTo, start, pageIndex, pageSize))
if err != nil {
return nil, err
}
var wrap struct {
List []map[string]any `json:"list"`
}
if err := json.Unmarshal(data, &wrap); err != nil {
return nil, fmt.Errorf("货运单列表响应格式错误: %w", err)
}
rows := make([]StockRow, 0, len(wrap.List))
for _, raw := range wrap.List {
id, ok := toInt64(raw["id"])
if !ok {
return nil, fmt.Errorf("货运单列表里有一行缺少合法的 id: %v", raw)
}
code, _ := raw["code"].(string)
rows = append(rows, StockRow{ID: id, Code: code, Raw: raw})
}
return rows, nil
}
// ---------- 货运单明细 ----------
// DetailItem 是货运单明细里的一个商品(t_stock.details[] 的一项),
// 见 08 §6.1。
type DetailItem struct {
ID int64
ProductID int64
ProductTitle string
ProductSpec string
ProductQty int
ProductPrice float64 // 元,`[必须]` 不是分,见 08 §5.1
ProductThumb int64
Raw map[string]any
}
// StockDetail 是一张货运单的明细,一张货运单可以有多个商品(Details)。
type StockDetail struct {
ID int64
Code string
Details []DetailItem
// Raw 是外层字段(不含 details),落库 syb_data 时和 StockRow.Raw 合并。
Raw map[string]any
}
// DetailListByStock 按货运单 id 批量取明细:
// POST /am/stock/detail/listByStock?hist=0
//
// `[必须]` 一次最多传 100 个 id,超了由调用方分批,见 08 §6。
func (c *Client) DetailListByStock(ctx context.Context, ids []int64) ([]StockDetail, error) {
if len(ids) == 0 {
return nil, nil
}
if len(ids) > 100 {
return nil, fmt.Errorf("单批查询明细最多 100 个 id,实际传了 %d 个,请分批调用", len(ids))
}
data, err := c.do(ctx, http.MethodPost, "/am/stock/detail/listByStock",
url.Values{"hist": {"0"}}, map[string]any{"ids": ids})
if err != nil {
return nil, err
}
var wrap struct {
List []map[string]any `json:"list"`
}
if err := json.Unmarshal(data, &wrap); err != nil {
return nil, fmt.Errorf("货运单明细响应格式错误: %w", err)
}
out := make([]StockDetail, 0, len(wrap.List))
for _, raw := range wrap.List {
id, ok := toInt64(raw["id"])
if !ok {
return nil, fmt.Errorf("货运单明细里有一行缺少合法的 id: %v", raw)
}
code, _ := raw["code"].(string)
var rawDetails []any
if dv, ok := raw["details"].([]any); ok {
rawDetails = dv
}
items := make([]DetailItem, 0, len(rawDetails))
for _, d := range rawDetails {
m, ok := d.(map[string]any)
if !ok {
continue
}
itemID, _ := toInt64(m["id"])
productID, _ := toInt64(m["productId"])
qty, _ := toInt64(m["productQty"])
price, _ := toFloat64(m["productPrice"])
thumb, _ := toInt64(m["productThumb"])
title, _ := m["productTitle"].(string)
spec, _ := m["productSpec"].(string)
items = append(items, DetailItem{
ID: itemID, ProductID: productID, ProductTitle: title, ProductSpec: spec,
ProductQty: int(qty), ProductPrice: price, ProductThumb: thumb, Raw: m,
})
}
outerRaw := make(map[string]any, len(raw))
for k, v := range raw {
if k == "details" {
continue
}
outerRaw[k] = v
}
out = append(out, StockDetail{ID: id, Code: code, Details: items, Raw: outerRaw})
}
return out, nil
}
// ---------- 类型转换:JSON 数字/字符串统一转 ----------
// toInt64 兼容 JSON 数字被 encoding/json 解成 float64、以及顺运宝个别
// 字段用字符串传数字的情况。
func toInt64(v any) (int64, bool) {
switch n := v.(type) {
case float64:
return int64(n), true
case int64:
return n, true
case json.Number:
i, err := n.Int64()
return i, err == nil
case string:
i, err := strconv.ParseInt(strings.TrimSpace(n), 10, 64)
return i, err == nil
default:
return 0, false
}
}
func toFloat64(v any) (float64, bool) {
switch n := v.(type) {
case float64:
return n, true
case json.Number:
f, err := n.Float64()
return f, err == nil
case string:
f, err := strconv.ParseFloat(strings.TrimSpace(n), 64)
return f, err == nil
default:
return 0, false
}
}