851 lines
29 KiB
Go
851 lines
29 KiB
Go
// 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"
|
||
"unicode/utf8"
|
||
)
|
||
|
||
// ErrSessionInvalid 表示服务端**明确**判定当前会话未登录或已过期
|
||
// (docs/admin/08-顺运宝接口.md §3.5)。调用方看到这个错误应该清掉
|
||
// 本地缓存的会话、提示操作员重新登录。
|
||
//
|
||
// `[必须]` 只有能明确判定"未登录"的情况才包成这个:HTTP 401/403,
|
||
// 或响应体 status=false 且 msg 含"未登录"/"登录过期",或 code 是 -2。
|
||
// **超时、5xx、JSON 格式错误一律是别的错误类型**,不能包成这个——
|
||
// 网络抖一下就判定登出会触发不必要的重新登录、验证码弹个不停,
|
||
// 还可能把本来有效的会话丢掉,见 §3.5 的理由和工单 #46。
|
||
var ErrSessionInvalid = errors.New("顺运宝会话未登录或已过期")
|
||
|
||
// ErrWriteResultUnknown 表示写请求可能已经到达顺运宝,但客户端无法确认结果。
|
||
// 调用方只能重新读取核对,绝不能自动重发同一个写请求。
|
||
var ErrWriteResultUnknown = errors.New("顺运宝写入结果未知")
|
||
|
||
type requestOutcomeUnknownError struct{ err error }
|
||
|
||
func (e requestOutcomeUnknownError) Error() string { return e.err.Error() }
|
||
func (e requestOutcomeUnknownError) Unwrap() error { return e.err }
|
||
|
||
// 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, requestOutcomeUnknownError{fmt.Errorf("请求顺运宝接口 %s 失败(网络问题,不代表未登录): %w", path, err)}
|
||
}
|
||
defer resp.Body.Close()
|
||
|
||
raw, err := io.ReadAll(resp.Body)
|
||
if err != nil {
|
||
return nil, requestOutcomeUnknownError{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, requestOutcomeUnknownError{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, requestOutcomeUnknownError{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=<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
|
||
}
|
||
|
||
// ---------- 货运单列表 ----------
|
||
|
||
// stockListPayload 组装 /am/stock/listTotal、/am/stock/list 共用的请求体。
|
||
func stockListPayload(query map[string]any, 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{query},
|
||
}
|
||
}
|
||
|
||
// listPayload 组装同步使用的日期范围条件,dvalue 是
|
||
// "起始日期,结束日期",YYYY-MM-DD,逗号分隔。
|
||
func listPayload(dateFrom, dateTo string, start, pageIndex, pageSize int) map[string]any {
|
||
return stockListPayload(map[string]any{
|
||
"dvalue": dateFrom + "," + dateTo,
|
||
"tableName": "t_stock",
|
||
"colName": "created",
|
||
"op": 0,
|
||
"type": 3,
|
||
"tableAlias": "t",
|
||
"optType": 0,
|
||
}, start, pageIndex, pageSize)
|
||
}
|
||
|
||
// orderNumberListPayload 使用顺运宝页面“全部单号”的原始 HAR 条件。
|
||
// allcode 会搜索多类单号,因此调用方还必须核对返回行的 code 与输入完全一致。
|
||
func orderNumberListPayload(orderNumber string, start, pageIndex, pageSize int) map[string]any {
|
||
return stockListPayload(map[string]any{
|
||
"dvalue": orderNumber,
|
||
"tableName": "t_stock",
|
||
"colName": "allcode",
|
||
"op": 6,
|
||
"type": 0,
|
||
"tableAlias": "t",
|
||
"optType": 1,
|
||
}, start, pageIndex, pageSize)
|
||
}
|
||
|
||
// 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
|
||
}
|
||
return parseListTotal(data)
|
||
}
|
||
|
||
func parseListTotal(data json.RawMessage) (int, error) {
|
||
var total int
|
||
if err := json.Unmarshal(data, &total); err != nil {
|
||
return 0, fmt.Errorf("listTotal 返回的总数格式错误: %s", string(data))
|
||
}
|
||
if total < 0 {
|
||
return 0, fmt.Errorf("listTotal 返回了负数: %d", total)
|
||
}
|
||
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) {
|
||
return c.listPage(ctx, listPayload(dateFrom, dateTo, start, pageIndex, pageSize))
|
||
}
|
||
|
||
func (c *Client) listPage(ctx context.Context, payload map[string]any) ([]StockRow, int, error) {
|
||
data, err := c.do(ctx, http.MethodPost, "/am/stock/list", nil,
|
||
payload)
|
||
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
|
||
}
|
||
|
||
const (
|
||
orderNumberPageSize = 20
|
||
orderNumberMaxMatches = 100
|
||
orderNumberMaxLength = 128
|
||
)
|
||
|
||
// ListByOrderNumber 使用顺运宝页面“全部单号”条件直接查询货运单。
|
||
// 返回值只包含 code 与输入完全相同的货运单;服务端返回不相关 code、重复 ID
|
||
// 或不完整分页时按协议错误处理,避免后续把档口入库码写到错误货运单。
|
||
func (c *Client) ListByOrderNumber(ctx context.Context, orderNumber string) ([]StockRow, error) {
|
||
orderNumber = strings.TrimSpace(orderNumber)
|
||
if orderNumber == "" {
|
||
return nil, fmt.Errorf("货运单号不能为空")
|
||
}
|
||
if utf8.RuneCountInString(orderNumber) > orderNumberMaxLength {
|
||
return nil, fmt.Errorf("货运单号不能超过 %d 个字符", orderNumberMaxLength)
|
||
}
|
||
for _, character := range orderNumber {
|
||
if character < 32 || character == 127 {
|
||
return nil, fmt.Errorf("货运单号不能包含控制字符")
|
||
}
|
||
}
|
||
|
||
data, err := c.do(ctx, http.MethodPost, "/am/stock/listTotal", nil,
|
||
orderNumberListPayload(orderNumber, 0, 1, orderNumberPageSize))
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
total, err := parseListTotal(data)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
if total == 0 {
|
||
return []StockRow{}, nil
|
||
}
|
||
if total > orderNumberMaxMatches {
|
||
return nil, fmt.Errorf("货运单号 %q 查询返回 %d 条,超过安全上限 %d",
|
||
orderNumber, total, orderNumberMaxMatches)
|
||
}
|
||
|
||
rows := make([]StockRow, 0, total)
|
||
seenIDs := make(map[int64]bool, total)
|
||
for start := 0; start < total; start += orderNumberPageSize {
|
||
pageIndex := start/orderNumberPageSize + 1
|
||
pageRows, pageCount, pageErr := c.listPage(ctx,
|
||
orderNumberListPayload(orderNumber, start, pageIndex, orderNumberPageSize))
|
||
if pageErr != nil {
|
||
return nil, pageErr
|
||
}
|
||
expected := orderNumberPageSize
|
||
if remaining := total - start; remaining < expected {
|
||
expected = remaining
|
||
}
|
||
if pageCount != expected {
|
||
return nil, fmt.Errorf("货运单号 %q 第 %d 页不完整:预期 %d 行,实际 %d 行",
|
||
orderNumber, pageIndex, expected, pageCount)
|
||
}
|
||
for _, row := range pageRows {
|
||
if row.Code != orderNumber {
|
||
return nil, fmt.Errorf("货运单号 %q 查询返回了不一致的 code %q", orderNumber, row.Code)
|
||
}
|
||
if seenIDs[row.ID] {
|
||
return nil, fmt.Errorf("货运单号 %q 查询重复返回货运单 id=%d", orderNumber, row.ID)
|
||
}
|
||
seenIDs[row.ID] = true
|
||
rows = append(rows, row)
|
||
}
|
||
}
|
||
if len(rows) != total {
|
||
return nil, fmt.Errorf("货运单号 %q 查询总数为 %d,实际读取 %d 行", orderNumber, total, len(rows))
|
||
}
|
||
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")
|
||
}
|
||
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
|
||
}
|
||
|
||
// DeleteInnerCode 清除一个货运明细的 innerExpCode。
|
||
// 请求只发送一次;结果未知时返回 ErrWriteResultUnknown,调用方不得重试。
|
||
func (c *Client) DeleteInnerCode(ctx context.Context, detailID int64) error {
|
||
if detailID <= 0 {
|
||
return fmt.Errorf("detailId 必须是正整数")
|
||
}
|
||
_, err := c.do(ctx, http.MethodGet, "/am/stock/detail/deleteInnerCode",
|
||
url.Values{"detailId": {strconv.FormatInt(detailID, 10)}}, nil)
|
||
return classifyInnerCodeWriteError(err)
|
||
}
|
||
|
||
// CreateInnerCodeDetail 为同一货运单额外创建一件零价占位商品,并返回新的 detailId。
|
||
// 请求只发送一次;结果未知时返回 ErrWriteResultUnknown,调用方不得自动重试。
|
||
func (c *Client) CreateInnerCodeDetail(ctx context.Context, stockID int64, title string) (int64, error) {
|
||
title = strings.TrimSpace(title)
|
||
if stockID <= 0 {
|
||
return 0, fmt.Errorf("货运单 id 必须是正整数")
|
||
}
|
||
if title == "" || utf8.RuneCountInString(title) > 128 {
|
||
return 0, fmt.Errorf("占位商品标题不能为空且不能超过 128 个字符")
|
||
}
|
||
data, err := c.do(ctx, http.MethodPost, "/am/stock/detail/createDetail", nil, map[string]any{
|
||
"id": nil, "productTitle": title, "productSpec": nil,
|
||
"productQty": 1, "productPrice": 0, "stockId": stockID,
|
||
})
|
||
if err != nil {
|
||
return 0, classifyInnerCodeWriteError(err)
|
||
}
|
||
var detailID int64
|
||
if err := json.Unmarshal(data, &detailID); err != nil || detailID <= 0 {
|
||
return 0, fmt.Errorf("%w:新增明细响应缺少有效 detailId", ErrWriteResultUnknown)
|
||
}
|
||
return detailID, nil
|
||
}
|
||
|
||
// UpdateDetailCode 把档口入库码写入货运明细的 innerExpCode。
|
||
// 请求只发送一次;结果未知时返回 ErrWriteResultUnknown,调用方不得重试。
|
||
func (c *Client) UpdateDetailCode(ctx context.Context, stockID, detailID int64, code string) error {
|
||
code = strings.TrimSpace(code)
|
||
if stockID <= 0 || detailID <= 0 {
|
||
return fmt.Errorf("货运单 id 和 detailId 必须是正整数")
|
||
}
|
||
if code == "" {
|
||
return fmt.Errorf("code 不能为空")
|
||
}
|
||
if utf8.RuneCountInString(code) > 128 {
|
||
return fmt.Errorf("code 不能超过 128 个字符")
|
||
}
|
||
for _, character := range code {
|
||
if character < 32 || character == 127 {
|
||
return fmt.Errorf("code 不能包含控制字符")
|
||
}
|
||
}
|
||
_, err := c.do(ctx, http.MethodPost, "/am/stock/detail/updateDetailCode", url.Values{
|
||
"t": {strconv.FormatInt(time.Now().UnixMilli(), 10)}, "id": {strconv.FormatInt(stockID, 10)},
|
||
"detailId": {strconv.FormatInt(detailID, 10)}, "code": {code},
|
||
}, nil)
|
||
return classifyInnerCodeWriteError(err)
|
||
}
|
||
|
||
func classifyInnerCodeWriteError(err error) error {
|
||
if err == nil {
|
||
return nil
|
||
}
|
||
var unknown requestOutcomeUnknownError
|
||
if errors.As(err, &unknown) {
|
||
return fmt.Errorf("%w:%v", ErrWriteResultUnknown, err)
|
||
}
|
||
return err
|
||
}
|
||
|
||
// ---------- 类型转换: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
|
||
}
|
||
}
|