feat: 安全回写档口入库码并支持核验恢复 (#234)
This commit is contained in:
+61
-4
@@ -24,6 +24,7 @@ import (
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
"unicode/utf8"
|
||||
)
|
||||
|
||||
// ErrSessionInvalid 表示服务端**明确**判定当前会话未登录或已过期
|
||||
@@ -37,6 +38,15 @@ import (
|
||||
// 还可能把本来有效的会话丢掉,见 §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)。
|
||||
//
|
||||
@@ -177,20 +187,20 @@ func (c *Client) do(ctx context.Context, method, path string, query url.Values,
|
||||
if err != nil {
|
||||
// 网络故障(超时、连不上、DNS 失败……)——`[必须]` 不能当成"未登录",
|
||||
// 见 ErrSessionInvalid 的注释和 08 §3.5。
|
||||
return nil, fmt.Errorf("请求顺运宝接口 %s 失败(网络问题,不代表未登录): %w", path, err)
|
||||
return nil, requestOutcomeUnknownError{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)
|
||||
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, fmt.Errorf("顺运宝接口 %s 返回 %d(服务端故障,不代表未登录)", path, resp.StatusCode)
|
||||
return nil, requestOutcomeUnknownError{fmt.Errorf("顺运宝接口 %s 返回 %d(服务端故障,不代表未登录)", path, resp.StatusCode)}
|
||||
}
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("顺运宝接口 %s 返回意外状态码 %d", path, resp.StatusCode)
|
||||
@@ -198,7 +208,7 @@ func (c *Client) do(ctx context.Context, method, path string, query url.Values,
|
||||
|
||||
var env envelope
|
||||
if err := json.Unmarshal(raw, &env); err != nil {
|
||||
return nil, fmt.Errorf("顺运宝接口 %s 响应不是合法 JSON(格式错误,不代表未登录): %w", path, err)
|
||||
return nil, requestOutcomeUnknownError{fmt.Errorf("顺运宝接口 %s 响应不是合法 JSON(格式错误,不代表未登录): %w", path, err)}
|
||||
}
|
||||
if !env.Status {
|
||||
if isSessionInvalidMessage(env.Msg, env.Code) {
|
||||
@@ -631,6 +641,53 @@ func (c *Client) DetailListByStock(ctx context.Context, ids []int64) ([]StockDet
|
||||
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)
|
||||
}
|
||||
|
||||
// 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.MethodGet, "/am/stock/detail/updateDetailCode", url.Values{
|
||||
"t": {"0"}, "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、以及顺运宝个别
|
||||
|
||||
@@ -431,6 +431,85 @@ func TestClient_DetailListByStock_超过100个id报错(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestClient_InnerCodeWrite_参数和路径正确且只发送一次(t *testing.T) {
|
||||
var requests atomic.Int32
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
requests.Add(1)
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
switch r.URL.Path {
|
||||
case "/am/stock/detail/deleteInnerCode":
|
||||
if r.URL.Query().Get("detailId") != "22" {
|
||||
t.Errorf("delete query=%v", r.URL.Query())
|
||||
}
|
||||
case "/am/stock/detail/updateDetailCode":
|
||||
query := r.URL.Query()
|
||||
if query.Get("t") != "0" || query.Get("id") != "11" || query.Get("detailId") != "22" || query.Get("code") != "DK-001" {
|
||||
t.Errorf("update query=%v", query)
|
||||
}
|
||||
default:
|
||||
t.Errorf("意外路径 %s", r.URL.Path)
|
||||
}
|
||||
io.WriteString(w, `{"status":true,"msg":"成功","data":null}`)
|
||||
}))
|
||||
defer server.Close()
|
||||
client, err := New(server.URL)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := client.DeleteInnerCode(context.Background(), 22); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := client.UpdateDetailCode(context.Background(), 11, 22, " DK-001 "); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if requests.Load() != 2 {
|
||||
t.Fatalf("每个写动作只能发一次请求,实际总请求 %d", requests.Load())
|
||||
}
|
||||
}
|
||||
|
||||
func TestClient_InnerCodeWrite_未知结果与明确业务失败分开(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
status int
|
||||
body string
|
||||
wantUnknown bool
|
||||
}{
|
||||
{"服务端故障", http.StatusInternalServerError, `oops`, true},
|
||||
{"成功响应损坏", http.StatusOK, `not-json`, true},
|
||||
{"明确业务失败", http.StatusOK, `{"status":false,"msg":"已打单数据不能清除","code":1}`, false},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(tc.status)
|
||||
io.WriteString(w, tc.body)
|
||||
}))
|
||||
defer server.Close()
|
||||
client, _ := New(server.URL)
|
||||
err := client.DeleteInnerCode(context.Background(), 22)
|
||||
if err == nil || errors.Is(err, ErrWriteResultUnknown) != tc.wantUnknown {
|
||||
t.Fatalf("err=%v unknown=%v want=%v", err, errors.Is(err, ErrWriteResultUnknown), tc.wantUnknown)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestClient_UpdateDetailCode_本地校验失败不发送请求(t *testing.T) {
|
||||
var requests atomic.Int32
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
requests.Add(1)
|
||||
}))
|
||||
defer server.Close()
|
||||
client, _ := New(server.URL)
|
||||
for _, code := range []string{"", "bad\ncode", strings.Repeat("长", 129)} {
|
||||
if err := client.UpdateDetailCode(context.Background(), 1, 2, code); err == nil {
|
||||
t.Errorf("code=%q 应被拒绝", code)
|
||||
}
|
||||
}
|
||||
if requests.Load() != 0 {
|
||||
t.Fatalf("本地校验失败不应发请求,实际 %d", requests.Load())
|
||||
}
|
||||
}
|
||||
|
||||
// ── 业务失败但不是登录问题 ──────────────────────────────
|
||||
|
||||
func TestClient_业务失败但不是登录问题时返回普通错误(t *testing.T) {
|
||||
|
||||
Reference in New Issue
Block a user