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
+589
View File
@@ -0,0 +1,589 @@
// 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
}
}
+423
View File
@@ -0,0 +1,423 @@
package syb
import (
"context"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
)
// `[必须]` 本文件全部用 httptest 起假服务端,绝不能打真实的
// shunyunbaoerp.com——打真站会污染对方数据、可能触发风控,见工单 #46。
// fakeJWT 造一个"看起来像"顺运宝 JWT 的 token:header.payload.signature,
// payload 是 base64url({"exp":...}),测试只关心 exp 能不能被正确解析出来。
func fakeJWT(t *testing.T, exp int64) string {
t.Helper()
payload := fmt.Sprintf(`{"authLogin":false,"exp":%d,"iat":%d,"username":"tester"}`, exp, exp-86400)
seg := base64.URLEncoding.WithPadding(base64.NoPadding).EncodeToString([]byte(payload))
return "header." + seg + ".signature"
}
func envelopeBody(t *testing.T, status bool, msg string, data any, code any) []byte {
t.Helper()
b, err := json.Marshal(map[string]any{
"status": status, "msg": msg, "data": data, "code": code,
})
if err != nil {
t.Fatalf("构造响应体失败: %v", err)
}
return b
}
// ── 验证码 + 登录:同一 Cookie Jar ──────────────────────
func TestClient_验证码和登录用同一个CookieJar(t *testing.T) {
var captchaCookieSeen, loginCookieSeen bool
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch {
case r.URL.Path == "/api/p/code1":
// 验证码接口种一个会话 Cookie。
http.SetCookie(w, &http.Cookie{Name: "erp_session", Value: "abc123", Path: "/"})
w.Header().Set("Content-Type", "image/jpeg")
w.Write([]byte("fake-jpeg-bytes"))
case r.URL.Path == "/am/auth/login":
// 登录请求必须带上验证码接口种下的 Cookie,
// 证明两次请求走的是同一个 Cookie Jar。
if ck, err := r.Cookie("erp_session"); err == nil && ck.Value == "abc123" {
loginCookieSeen = true
}
w.Write(envelopeBody(t, true, "登录成功", map[string]any{
"user": map[string]any{"id": 1001, "username": "tester"},
"token": fakeJWT(t, time.Now().Add(2*time.Hour).Unix()),
}, nil))
}
}))
defer srv.Close()
c, err := New(srv.URL)
if err != nil {
t.Fatalf("创建客户端失败: %v", err)
}
cap, err := c.FetchCaptcha(context.Background())
if err != nil {
t.Fatalf("获取验证码失败: %v", err)
}
if len(cap.Image) == 0 || cap.ContentType != "image/jpeg" {
t.Fatalf("验证码内容不对: %+v", cap)
}
captchaCookieSeen = true // 只要走到这里说明请求成功了
result, err := c.Login(context.Background(), "tester", "password123", "AB12")
if err != nil {
t.Fatalf("登录失败: %v", err)
}
if !captchaCookieSeen || !loginCookieSeen {
t.Fatal("验证码和登录应该用同一个 Cookie Jar,但登录请求没带上验证码接口种的 Cookie")
}
if result.User.ID != 1001 || result.User.Username != "tester" {
t.Errorf("登录结果不对: %+v", result.User)
}
}
// ── 会话有效期:min(JWT exp, 24h) ────────────────────────
func TestClient_Login_有效期取JWT剩余和24小时的较小值(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// JWT 只剩 2 小时,应该取 2 小时,不是 24 小时。
w.Write(envelopeBody(t, true, "ok", map[string]any{
"user": map[string]any{"id": 1, "username": "tester"},
"token": fakeJWT(t, time.Now().Add(2*time.Hour).Unix()),
}, nil))
}))
defer srv.Close()
c, _ := New(srv.URL)
result, err := c.Login(context.Background(), "tester", "pw", "code")
if err != nil {
t.Fatalf("登录失败: %v", err)
}
remain := time.Until(result.ExpiresAt)
if remain > 3*time.Hour || remain < time.Hour {
t.Errorf("有效期应该接近 JWT 剩余的 2 小时,实际剩 %v", remain)
}
}
func TestClient_Login_JWT解析失败时退化成24小时(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Write(envelopeBody(t, true, "ok", map[string]any{
"user": map[string]any{"id": 1, "username": "tester"},
"token": "不是一个合法的JWT",
}, nil))
}))
defer srv.Close()
c, _ := New(srv.URL)
result, err := c.Login(context.Background(), "tester", "pw", "code")
if err != nil {
t.Fatalf("登录失败: %v", err)
}
remain := time.Until(result.ExpiresAt)
if remain > 25*time.Hour || remain < 23*time.Hour {
t.Errorf("JWT 解析失败时应该退化成 24 小时,实际剩 %v", remain)
}
}
// ── §3.5:区分"明确未登录"和"网络故障" ───────────────────
func TestClient_CheckSession_HTTP401判定为未登录(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusUnauthorized)
}))
defer srv.Close()
c, _ := New(srv.URL)
err := c.CheckSession(context.Background(), 1001, "tester")
if !errors.Is(err, ErrSessionInvalid) {
t.Fatalf("HTTP 401 应该判定为未登录,实际: %v", err)
}
}
func TestClient_CheckSession_业务码未登录判定为未登录(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Write(envelopeBody(t, false, "登录过期,请重新登录", nil, "-2"))
}))
defer srv.Close()
c, _ := New(srv.URL)
err := c.CheckSession(context.Background(), 1001, "tester")
if !errors.Is(err, ErrSessionInvalid) {
t.Fatalf("msg 含「登录过期」应该判定为未登录,实际: %v", err)
}
}
func TestClient_CheckSession_id或username不一致判定为未登录(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Write(envelopeBody(t, true, "ok", map[string]any{"id": 9999, "username": "别人"}, nil))
}))
defer srv.Close()
c, _ := New(srv.URL)
err := c.CheckSession(context.Background(), 1001, "tester")
if !errors.Is(err, ErrSessionInvalid) {
t.Fatalf("id/username 不一致(串号)应该判定为未登录,实际: %v", err)
}
}
func TestClient_CheckSession_超时不判定为未登录(t *testing.T) {
// `[必须]` 08 §3.5 最重要的一条:网络故障不能被误判成"未登录",
// 否则网络抖一下就会触发重新登录、弹验证码,还可能把有效会话丢掉。
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
time.Sleep(200 * time.Millisecond)
w.Write(envelopeBody(t, true, "ok", map[string]any{"id": 1001, "username": "tester"}, nil))
}))
defer srv.Close()
c, _ := New(srv.URL)
c.http.Timeout = 50 * time.Millisecond // 故意设一个比服务端延迟短的超时
err := c.CheckSession(context.Background(), 1001, "tester")
if err == nil {
t.Fatal("超时应该返回错误")
}
if errors.Is(err, ErrSessionInvalid) {
t.Fatalf("超时不能被判定为「未登录」,实际: %v", err)
}
}
func TestClient_CheckSession_HTTP5xx不判定为未登录(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusBadGateway)
}))
defer srv.Close()
c, _ := New(srv.URL)
err := c.CheckSession(context.Background(), 1001, "tester")
if err == nil {
t.Fatal("5xx 应该返回错误")
}
if errors.Is(err, ErrSessionInvalid) {
t.Fatalf("5xx(服务端故障)不能被判定为「未登录」,实际: %v", err)
}
}
func TestClient_CheckSession_响应格式错误不判定为未登录(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Write([]byte("这不是 JSON"))
}))
defer srv.Close()
c, _ := New(srv.URL)
err := c.CheckSession(context.Background(), 1001, "tester")
if err == nil {
t.Fatal("格式错误应该返回错误")
}
if errors.Is(err, ErrSessionInvalid) {
t.Fatalf("响应格式错误不能被判定为「未登录」,实际: %v", err)
}
}
func TestClient_CheckSession_会话有效时返回nil(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Write(envelopeBody(t, true, "ok", map[string]any{"id": 1001, "username": "tester"}, nil))
}))
defer srv.Close()
c, _ := New(srv.URL)
if err := c.CheckSession(context.Background(), 1001, "tester"); err != nil {
t.Fatalf("会话有效时应该返回 nil,实际: %v", err)
}
}
// ── Cookie 持久化:导出 → 导入 ──────────────────────────
func TestClient_Cookie导出后可以导入到新客户端(t *testing.T) {
var seenCookieValue string
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/set" {
http.SetCookie(w, &http.Cookie{Name: "erp_session", Value: "the-cookie-value", Path: "/"})
return
}
if ck, err := r.Cookie("erp_session"); err == nil {
seenCookieValue = ck.Value
}
}))
defer srv.Close()
c1, _ := New(srv.URL)
req, _ := http.NewRequest(http.MethodGet, srv.URL+"/set", nil)
resp, err := c1.http.Do(req)
if err != nil {
t.Fatalf("请求失败: %v", err)
}
resp.Body.Close()
cookiesJSON, err := c1.ExportCookiesJSON()
if err != nil {
t.Fatalf("导出 Cookie 失败: %v", err)
}
if !strings.Contains(cookiesJSON, "the-cookie-value") {
t.Fatalf("导出的 Cookie JSON 应该包含 Cookie 的值,实际: %s", cookiesJSON)
}
// 新客户端(模拟重启 Admin 后新建的 Client),导入缓存的 Cookie。
c2, _ := New(srv.URL)
if err := c2.ImportCookiesJSON(cookiesJSON); err != nil {
t.Fatalf("导入 Cookie 失败: %v", err)
}
if err := c2.CheckSession(context.Background(), 1, "x"); err != nil && !errors.Is(err, ErrSessionInvalid) {
// 忽略——这里只是想借这个请求确认 Cookie 被带上了,不关心业务结果
}
if seenCookieValue != "the-cookie-value" {
t.Fatalf("新客户端应该带上导入的 Cookie 发请求,实际服务端看到的值: %q", seenCookieValue)
}
}
// ── 货运单列表 + 明细 ────────────────────────────────────
func TestClient_ListTotal和ListPage(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
var body map[string]any
json.NewDecoder(r.Body).Decode(&body)
queries, _ := body["queries"].([]any)
if len(queries) != 1 {
t.Errorf("queries 应该有 1 个条件,实际 %d 个", len(queries))
}
q := queries[0].(map[string]any)
if q["dvalue"] != "2026-07-25,2026-07-28" {
t.Errorf("dvalue 拼接不对: %v", q["dvalue"])
}
switch r.URL.Path {
case "/am/stock/listTotal":
w.Write(envelopeBody(t, true, "ok", 1, nil))
case "/am/stock/list":
w.Write(envelopeBody(t, true, "ok", map[string]any{
"list": []map[string]any{
{"id": 75104587, "code": "260728TB95MJTQ", "amtOrder": 61200},
},
}, nil))
}
}))
defer srv.Close()
c, _ := New(srv.URL)
total, err := c.ListTotal(context.Background(), "2026-07-25", "2026-07-28", 20)
if err != nil {
t.Fatalf("listTotal 失败: %v", err)
}
if total != 1 {
t.Fatalf("总数应该是 1,实际 %d", total)
}
rows, err := c.ListPage(context.Background(), "2026-07-25", "2026-07-28", 0, 1, 20)
if err != nil {
t.Fatalf("list 失败: %v", err)
}
if len(rows) != 1 || rows[0].ID != 75104587 || rows[0].Code != "260728TB95MJTQ" {
t.Fatalf("列表结果不对: %+v", rows)
}
}
func TestClient_DetailListByStock_一单多商品(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Query().Get("hist") != "0" {
t.Errorf("hist 参数应该是 0,实际 %q", r.URL.Query().Get("hist"))
}
var body map[string]any
json.NewDecoder(r.Body).Decode(&body)
ids, _ := body["ids"].([]any)
if len(ids) != 1 || ids[0].(float64) != 75104587 {
t.Errorf("ids 传递不对: %v", ids)
}
w.Write(envelopeBody(t, true, "ok", map[string]any{
"list": []map[string]any{
{
"id": 75104587, "code": "260728TB95MJTQ", "shopName": "测试店铺",
"amtOrder": 612.0,
"details": []map[string]any{
{
"id": 145306175, "productId": 50209124255,
"productTitle": "蕾絲花邊拼接背心女", "productSpec": "白色,L【建議50-60公斤】",
"productQty": 1, "productPrice": 239.0, "productThumb": 190639637,
},
{
"id": 145306176, "productId": 50209124256,
"productTitle": "牛仔裤", "productSpec": "黑色,M",
"productQty": 2, "productPrice": 439.0, "productThumb": 190639638,
},
},
},
},
}, nil))
}))
defer srv.Close()
c, _ := New(srv.URL)
details, err := c.DetailListByStock(context.Background(), []int64{75104587})
if err != nil {
t.Fatalf("查询明细失败: %v", err)
}
if len(details) != 1 {
t.Fatalf("应该有 1 张货运单,实际 %d", len(details))
}
d := details[0]
if d.ID != 75104587 || d.Code != "260728TB95MJTQ" {
t.Fatalf("外层字段不对: %+v", d)
}
if len(d.Details) != 2 {
t.Fatalf("一张货运单应该拆出 2 个商品明细,实际 %d 个", len(d.Details))
}
if d.Details[0].ProductID != 50209124255 || d.Details[0].ProductSpec != "白色,L【建議50-60公斤】" {
t.Errorf("第一个商品明细字段不对: %+v", d.Details[0])
}
if d.Details[0].ProductPrice != 239.0 {
t.Errorf("单价应该是明细接口的原始值(元,未换算),实际 %v", d.Details[0].ProductPrice)
}
if d.Details[1].ProductID != 50209124256 || d.Details[1].ProductQty != 2 {
t.Errorf("第二个商品明细字段不对: %+v", d.Details[1])
}
// details 不应该出现在外层 Raw 里,避免落库时重复。
if _, ok := d.Raw["details"]; ok {
t.Error("StockDetail.Raw 不应该包含 details(那是嵌套结构,已经拆到 Details 字段)")
}
}
func TestClient_DetailListByStock_超过100个id报错(t *testing.T) {
c, _ := New("https://example.invalid")
ids := make([]int64, 101)
_, err := c.DetailListByStock(context.Background(), ids)
if err == nil {
t.Fatal("超过 100 个 id 应该报错,不应该真的发请求")
}
}
// ── 业务失败但不是登录问题 ──────────────────────────────
func TestClient_业务失败但不是登录问题时返回普通错误(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Write(envelopeBody(t, false, "参数错误", nil, "400"))
}))
defer srv.Close()
c, _ := New(srv.URL)
_, err := c.ListTotal(context.Background(), "2026-01-01", "2026-01-02", 20)
if err == nil {
t.Fatal("业务失败应该返回错误")
}
if errors.Is(err, ErrSessionInvalid) {
t.Fatalf("普通业务错误不应该被误判为未登录,实际: %v", err)
}
if !strings.Contains(err.Error(), "参数错误") {
t.Errorf("错误信息应该带上服务端的 msg,实际: %v", err)
}
}
+109
View File
@@ -0,0 +1,109 @@
package syb
// columnSpec 是货运单列表请求体里 columns 数组的一项:声明服务端
// 要返回哪一列。
//
// `[必须]` 完整照抄 raw_data/shunyunbaoerp_single.py 的 COLUMN_SPECS
// (72 项),不要自己删减——docs/admin/08-顺运宝接口.md §4.1 明确说
// "服务端可能依赖这批列做联表",删了不知道会不会漏别的字段。
type columnSpec struct {
TableName string
ColName string
FieldName string
HasAlias int
TableAlias string
}
// stockColumnSpecs 是货运单列表查询用到的 72 个列定义,
// 逐项抄自 raw_data/shunyunbaoerp_single.py 的 COLUMN_SPECS。
var stockColumnSpecs = []columnSpec{
{"t_stock", "created", "created", 0, "t"},
{"t_stock", "order_code", "orderCode", 0, "t"},
{"t_stock", "printer", "printer", 0, "t"},
{"t_stock", "weight_time", "weightTime", 0, "t"},
{"t_stock", "weight_inputer", "weightInputer", 0, "t"},
{"t_stock", "pkg_time", "pkgTime", 0, "t"},
{"t_stock", "code", "code", 0, "t"},
{"t_stock", "status", "status", 0, "t"},
{"t_stock", "order_status", "orderStatus", 0, "t"},
{"t_stock", "purchase_status", "purchaseStatus", 0, "t"},
{"t_stock", "exp_code", "expCode", 0, "t"},
{"t_stock", "exp_page_code", "expPageCode", 0, "t"},
{"t_stock", "exp_allow_print", "expAllowPrint", 0, "t"},
{"t_stock", "exp_page_status", "expPageStatus", 0, "t"},
{"t_stock", "page_id", "pageId", 0, "t"},
{"t_stock", "upload_time", "uploadTime", 0, "t"},
{"t_stock", "pay_time", "payTime", 0, "t"},
{"t_stock", "shop_day_to_ship", "shopDayToShip", 0, "t"},
{"t_stock", "remark9", "tsremark9", 1, "t"},
{"t_stock", "remark9", "remark9", 0, "t"},
{"t_stock", "detail_qty", "detailQty", 0, "t"},
{"t_stock", "order_qty", "orderQty", 0, "t"},
{"t_stock_detail", "inner_exp_code", "innerExpCode", 0, "t7"},
{"t_stock_detail", "shelf_code", "shelfCode", 0, "t7"},
{"t_stock", "shelf_code", "tsshelfCode", 1, "t"},
{"t_stock", "store_type", "storeType", 0, "t"},
{"t_stock", "order_bag_code", "orderBagCode", 0, "t"},
{"t_stock", "weight_cust_pkg", "weightCustPkg", 0, "t"},
{"t_stock", "weight_consign", "weightConsign", 0, "t"},
{"t_stock", "amt_order", "amtOrder", 0, "t"},
{"t_stock_append", "offline_amount", "offlineAmount", 0, "t8"},
{"t_stock_append", "escrow_amount", "escrowAmount", 0, "t8"},
{"t_stock", "exp_cod", "expCod", 0, "t"},
{"t_stock", "exp_company", "expCompany", 0, "t"},
{"t_stock", "transport", "transport", 0, "t"},
{"t_stock", "order_origin", "orderOrigin", 0, "t"},
{"t_stock", "exp_out_type", "expOutType", 0, "t"},
{"t_stock", "order_platform", "orderPlatform", 0, "t"},
{"t_stock", "exp_pkg_type", "expPkgType", 0, "t"},
{"t_stock", "exp_pkg_code", "expPkgCode", 0, "t"},
{"t_stock", "exp_batch", "expBatch", 0, "t"},
{"t_stock", "exp_ti_huo", "expTiHuo", 0, "t"},
{"t_stock", "print_time", "printTime", 0, "t"},
{"t_stock", "receiver", "receiver", 0, "t"},
{"t_stock", "receiver_tel", "receiverTel", 0, "t"},
{"t_stock", "receiver_addr", "receiverAddr", 0, "t"},
{"t_stock", "receiver_shop_name", "receiverShopName", 0, "t"},
{"t_stock", "receiver_shop_code", "receiverShopCode", 0, "t"},
{"t_stock", "product_name", "productName", 0, "t"},
{"t_stock", "is_cancel", "isCancel", 0, "t"},
{"t_stock", "err_msg", "errMsg", 0, "t"},
{"t_stock", "remark2", "remark2", 0, "t"},
{"t_stock", "remark1", "remark1", 0, "t"},
{"t_stock", "note", "note", 0, "t"},
{"t_store", "name", "name", 0, "t1"},
{"sys_user", "fullname", "sufullname", 1, "t3"},
{"sys_user", "dept_label_path", "deptLabelPath", 0, "t3"},
{"t_stock", "shop_name", "shopName", 0, "t"},
{"t_shop", "shop_id", "shopId", 0, "t6"},
{"t_stock", "remark4", "remark4", 0, "t"},
{"t_stock", "remark7", "remark7", 0, "t"},
{"t_stock", "package_time", "packageTime", 0, "t"},
{"t_stock", "packer", "packer", 0, "t"},
{"t_stock", "pack_type", "packType", 0, "t"},
{"t_stock", "track_status", "trackStatus", 0, "t"},
{"t_stock", "track_desc", "trackDesc", 0, "t"},
{"t_stock", "err_status", "errStatus", 0, "t"},
{"t_stock", "err_time", "errTime", 0, "t"},
{"t_stock", "err_msg", "tserrMsg", 1, "t"},
{"t_stock", "remark2", "tsremark2", 1, "t"},
{"t_stock", "remark1", "tsremark1", 1, "t"},
{"t_stock", "product_volume_str", "productVolumeStr", 0, "t"},
}
// columnsPayload 把 stockColumnSpecs 转成请求体要的 JSON 形状:
//
// {"tableName":"t_stock","colName":"created","fieldName":"created","hasAlias":0,"tableAlias":"t"}
func columnsPayload() []map[string]any {
out := make([]map[string]any, 0, len(stockColumnSpecs))
for _, c := range stockColumnSpecs {
out = append(out, map[string]any{
"tableName": c.TableName,
"colName": c.ColName,
"fieldName": c.FieldName,
"hasAlias": c.HasAlias,
"tableAlias": c.TableAlias,
})
}
return out
}