// 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 } // ---------- 登录:验证码自动识别(工单 #47) ---------- // DefaultOcrMaxAttempts 是自动识别失败后的重试次数上限,maxAttempts<=0 // 时退化成这个值——示例脚本 raw_data/shunyunbaoerp_single.py 用的也是 5。 const DefaultOcrMaxAttempts = 5 // LoginWithOCR 尝试用 OCR 服务自动识别验证码并登录,最多重试 maxAttempts 次。 // // `[必须]` 见 docs/admin/08-顺运宝接口.md「验证码自动识别」一节和工单 #47: // - 每次重试都要重新取一张验证码图——同一张图再识别一次结果不会变, // 纯属浪费,而且验证码可能已经被上一次失败的登录作废; // - 识别结果必须**非空且恰好 4 位字母数字**才拿去登录,过滤掉 // OCR 可能带回的空格和标点;不满足就换图重试,不拿去试登录—— // 白费一次登录尝试,而且频繁错误登录可能触发对方风控; // - OCR 请求本身失败(网络/超时/格式错误/业务失败)判定为"服务不可用", // 立即降级,不占用重试次数——服务本身连不上,重试没有意义; // - 达到 maxAttempts 仍没登录成功,降级并说明已尝试的次数。 // // `[必须]` 本函数**不返回 error**:外部 OCR 服务失败、识别失败都是 // 预期路径,不是异常——调用方应该把非空的返回原因展示给操作员并弹 // 手工输入框,而不是当成系统错误处理。返回 (result, "") 表示登录成功; // 返回 (nil, reason) 表示需要降级到手工,reason 是给操作员看的说明。 func (c *Client) LoginWithOCR(ctx context.Context, ocr *OcrClient, username, password string, maxAttempts int) (*LoginResult, string) { if ocr == nil { return nil, "验证码识别服务未配置,请手工输入" } if maxAttempts <= 0 { maxAttempts = DefaultOcrMaxAttempts } for attempt := 1; attempt <= maxAttempts; attempt++ { captcha, err := c.FetchCaptcha(ctx) if err != nil { return nil, fmt.Sprintf("获取验证码图片失败(%s),请手工输入", err.Error()) } code, err := ocr.Recognize(ctx, captcha.Image) if err != nil { return nil, fmt.Sprintf("验证码识别服务暂时不可用(%s),请手工输入", err.Error()) } code = filterAlnum(code) if len(code) != 4 { // 识别结果为空或长度不对,说明这张图没识别对,换图重试, // 不拿去登录(08 §OCR:不是 4 位就不要拿去登录)。 continue } result, err := c.Login(ctx, username, password, code) if err == nil { return result, "" } // 登录失败(验证码错/密码错,08 §9 未区分),换一张图重试。 } return nil, fmt.Sprintf("自动识别验证码失败(已尝试 %d 次),请手工输入", maxAttempts) } // filterAlnum 只保留字母和数字,滤掉 OCR 可能带回的空格和标点 // (照抄 raw_data/shunyunbaoerp_single.py 的 ocr_captcha() 的做法)。 func filterAlnum(s string) string { var b strings.Builder for _, r := range s { switch { case r >= 'a' && r <= 'z', r >= 'A' && r <= 'Z', r >= '0' && r <= '9': b.WriteRune(r) } } return b.String() } // CheckSession 用 GET /am/user/get?id= 校验当前 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。 // 返回响应内的 total;HAR 证明它是当前页条数,不是筛选范围总数。 func (c *Client) ListPage(ctx context.Context, dateFrom, dateTo string, start, pageIndex, pageSize int) ([]StockRow, int, error) { data, err := c.do(ctx, http.MethodPost, "/am/stock/list", nil, listPayload(dateFrom, dateTo, start, pageIndex, pageSize)) if err != nil { return nil, 0, err } var wrap struct { List []map[string]any `json:"list"` Total *int `json:"total"` } if err := json.Unmarshal(data, &wrap); err != nil { return nil, 0, fmt.Errorf("货运单列表响应格式错误: %w", err) } if wrap.Total == nil || *wrap.Total < 0 { return nil, 0, fmt.Errorf("货运单列表响应缺少合法的 total") } if *wrap.Total != len(wrap.List) { return nil, 0, fmt.Errorf("货运单列表响应的当前页条数为 %d,但实际返回 %d 行", *wrap.Total, len(wrap.List)) } rows := make([]StockRow, 0, len(wrap.List)) for _, raw := range wrap.List { id, ok := toInt64(raw["id"]) if !ok { return nil, 0, fmt.Errorf("货运单列表里有一行缺少合法的 id") } code, _ := raw["code"].(string) rows = append(rows, StockRow{ID: id, Code: code, Raw: raw}) } return rows, *wrap.Total, 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") } 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 } }