From af3dd5563b08bef2cdde7542631d4137674fb577 Mon Sep 17 00:00:00 2001 From: QiuSW <105186638@qq.com> Date: Mon, 24 Aug 2026 17:31:34 +0800 Subject: [PATCH] fix(#79): retry transient SYB read failures --- docs/03-business-rules-and-glossary.md | 5 +- docs/12-syb-erp-interface.md | 12 +- server/app/goauto/sybclient/client.go | 59 +++++++-- server/app/goauto/sybclient/client_test.go | 145 +++++++++++++++++++++ 4 files changed, 208 insertions(+), 13 deletions(-) diff --git a/docs/03-business-rules-and-glossary.md b/docs/03-business-rules-and-glossary.md index 737ed94..e0230be 100644 --- a/docs/03-business-rules-and-glossary.md +++ b/docs/03-business-rules-and-glossary.md @@ -2,8 +2,8 @@ generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件) wiki_page: Business-Rules-and-Glossary wiki_url: https://git.ilapage.cn/OPC/goauto/wiki/Business-Rules-and-Glossary.- -wiki_revision: 3d2a64c6638229ff95a8ac5a5680bb41f0dcb91d -synchronized_at: 2026-08-24T09:01:14Z +wiki_revision: 45bbde203b18cee7031aed8129f72a6e8f883033 +synchronized_at: 2026-08-24T09:26:57Z # 业务规则与术语 @@ -190,6 +190,7 @@ synchronized_at: 2026-08-24T09:01:14Z - 定时同步与保留的手动导入 API 共用同一个导入服务和 `syb_sync_run` 记录;定时任务的操作人显示为“系统定时同步”。 - 同一时刻只允许一个 SYB 同步任务运行。上一次仍在运行时,本次定时触发直接跳过,不排队、不并发,也不立即重试;等待下一小时再次触发。 - 定时任务失败时明确记录失败原因,不自动重试。服务重启后由既有启动恢复逻辑处理遗留的运行中记录。 +- 单次同步内部的 SYB 只读请求(货运单总数、列表和明细)遇到暂时网络故障、5xx 或异常响应时最多执行 3 次,分别退避 1 秒、2 秒,单次 HTTP 总超时 60 秒;这不等于失败任务自动重试。明确未登录、业务失败、数据完整性错误以及任何写操作均不自动重试。 - SYB 商品页不再提供“导入”和“同步记录”快捷按钮,也不查询、展示或轮询同步状态;后台同步成功、失败或中断均不在商品页弹出消息,商品数据由用户主动查询或刷新获取。 - 独立“SYB 同步记录”页面是同步结果的唯一 Web 展示位置,保留运行状态、进度、数量、失败原因和店铺统计;go-admin 定时任务中的 `GoAutoSYBHourlySync` 提供“执行记录”入口。管理员可在同步记录页人工发起覆盖昨天和今天的同步,采购员只读;人工与定时同步共用导入服务、执行记录和互斥,不排队、不并发,也不立即重试。 diff --git a/docs/12-syb-erp-interface.md b/docs/12-syb-erp-interface.md index 2a054c1..992f20a 100644 --- a/docs/12-syb-erp-interface.md +++ b/docs/12-syb-erp-interface.md @@ -2,8 +2,8 @@ generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件) wiki_page: SYB-ERP-Interface-Contract wiki_url: https://git.ilapage.cn/OPC/goauto/wiki/SYB-ERP-Interface-Contract.- -wiki_revision: f92d8e766d3307ac80abfd85f664fb0ab87ec4c2 -synchronized_at: 2026-08-20T03:53:19Z +wiki_revision: 7a3513c2d59a4c0557b90fabc4aa46313e377fe5 +synchronized_at: 2026-08-24T09:28:34Z # 12 顺云宝(SYB)ERP 接口契约 @@ -212,6 +212,14 @@ Admin 默认 `max_matches = 10000`,可以在配置中调整;上限针对整 预检总数。历史日期出现前后总数变化、短页、重复 ID 或唯一 ID 不足时立即失败, 不推进游标。 + +`[必须]` `listTotal`、`list` 和 `detail/listByStock` 属于语义只读查询,虽然 +使用 POST,也只允许在超时、网络故障、响应读取失败、HTTP 5xx 或响应信封格式异常时 +有限重试:单次 HTTP 总超时 60 秒,最多执行 3 次,重试前分别等待 1 秒、2 秒,等待须 +响应 context 取消。HTTP 401/403、明确会话失效、业务失败、接口数据完整性错误和本地 +校验失败不得重试;登录、验证码、单件码写入和任何回填请求也不得使用该机制。重试 +耗尽后返回最后一次错误,外层继续保留日期、页码和已获取数量上下文并将同步标为失败。 + 今天的货运单会在同步期间持续新增。只有 UTC+8 下的今天发生上述快照漂移时, 允许只重试今天的列表分页,最多 3 次;已经完成的历史日期不得重复拉取,每次尝试 也必须使用独立 ID 集合。第三次仍不稳定时,可以对最后一次取得的合法唯一 ID diff --git a/server/app/goauto/sybclient/client.go b/server/app/goauto/sybclient/client.go index d52bb52..d217c1b 100644 --- a/server/app/goauto/sybclient/client.go +++ b/server/app/goauto/sybclient/client.go @@ -60,8 +60,14 @@ type Client struct { baseURL string http *http.Client jar *cookiejar.Jar + // readRetryDelays only applies to the explicitly listed, semantically + // read-only stock endpoints. It is a field so tests can remove real waits + // without changing the production retry boundary. + readRetryDelays []time.Duration } +var defaultReadRetryDelays = []time.Duration{time.Second, 2 * time.Second} + // New 创建一个新的顺运宝客户端,带一个空的 Cookie Jar。 func New(baseURL string) (*Client, error) { baseURL = strings.TrimRight(strings.TrimSpace(baseURL), "/") @@ -73,13 +79,14 @@ func New(baseURL string) (*Client, error) { return nil, fmt.Errorf("创建顺运宝客户端的 Cookie Jar 失败: %w", err) } return &Client{ - baseURL: baseURL, - jar: jar, + baseURL: baseURL, + jar: jar, + readRetryDelays: append([]time.Duration(nil), defaultReadRetryDelays...), http: &http.Client{ Jar: jar, - // 5 秒连接 + 30 秒读取是示例脚本用的值(见 上游示例脚本), - // 这里简化成一个总超时,量级一致。 - Timeout: 30 * time.Second, + // SYB 偶尔会在繁忙分页中超过 30 秒才返回响应头。单次请求允许 + // 60 秒,外层同步仍有 30 分钟总时限。 + Timeout: 60 * time.Second, }, }, nil } @@ -223,6 +230,40 @@ func (c *Client) do(ctx context.Context, method, path string, query url.Values, return env.Data, nil } +// doReadWithRetry is deliberately not a generic POST retry. The caller may +// only use it for SYB endpoints whose POST requests are queries and have no +// write semantics. Login, captcha and inner-code writes continue to call do +// exactly once. +func (c *Client) doReadWithRetry(ctx context.Context, path string, query url.Values, body any) (json.RawMessage, error) { + for attempt := 0; ; attempt++ { + data, err := c.do(ctx, http.MethodPost, path, query, body) + if err == nil { + return data, nil + } + if ctx.Err() != nil { + return nil, ctx.Err() + } + var transient requestOutcomeUnknownError + if !errors.As(err, &transient) || attempt >= len(c.readRetryDelays) { + return nil, err + } + + delay := c.readRetryDelays[attempt] + timer := time.NewTimer(delay) + select { + case <-ctx.Done(): + if !timer.Stop() { + select { + case <-timer.C: + default: + } + } + return nil, ctx.Err() + case <-timer.C: + } + } +} + // isSessionInvalidMessage 判断业务失败信息是不是"明确未登录", // 规则见 12 §3.5:msg 含"未登录"/"登录过期",或 code 是 -2 // (数字或字符串两种写法都算,服务端返回哪种没有实测确认过)。 @@ -512,7 +553,7 @@ func orderNumberListPayload(orderNumber string, start, pageIndex, pageSize int) // ListTotal 查某个日期范围内的货运单总数:POST /am/stock/listTotal。 // `[必须]` data 是裸整数,不是对象,见 12 §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, + data, err := c.doReadWithRetry(ctx, "/am/stock/listTotal", nil, listPayload(dateFrom, dateTo, 0, 1, pageSize)) if err != nil { return 0, err @@ -546,7 +587,7 @@ func (c *Client) ListPage(ctx context.Context, dateFrom, dateTo string, start, p } 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, + data, err := c.doReadWithRetry(ctx, "/am/stock/list", nil, payload) if err != nil { return nil, 0, err @@ -601,7 +642,7 @@ func (c *Client) ListByOrderNumber(ctx context.Context, orderNumber string) ([]S } } - data, err := c.do(ctx, http.MethodPost, "/am/stock/listTotal", nil, + data, err := c.doReadWithRetry(ctx, "/am/stock/listTotal", nil, orderNumberListPayload(orderNumber, 0, 1, orderNumberPageSize)) if err != nil { return nil, err @@ -688,7 +729,7 @@ func (c *Client) DetailListByStock(ctx context.Context, ids []int64) ([]StockDet return nil, fmt.Errorf("单批查询明细最多 100 个 id,实际传了 %d 个,请分批调用", len(ids)) } - data, err := c.do(ctx, http.MethodPost, "/am/stock/detail/listByStock", + data, err := c.doReadWithRetry(ctx, "/am/stock/detail/listByStock", url.Values{"hist": {"0"}}, map[string]any{"ids": ids}) if err != nil { return nil, err diff --git a/server/app/goauto/sybclient/client_test.go b/server/app/goauto/sybclient/client_test.go index 91b3e46..447aa7b 100644 --- a/server/app/goauto/sybclient/client_test.go +++ b/server/app/goauto/sybclient/client_test.go @@ -335,6 +335,151 @@ func TestClient_ListTotal和ListPage(t *testing.T) { } } +func TestClient_只读货运接口遇到暂时故障后重试恢复(t *testing.T) { + for _, test := range []struct { + name string + path string + invoke func(*Client) error + data any + }{ + { + name: "总数", path: "/am/stock/listTotal", data: 1, + invoke: func(client *Client) error { + _, err := client.ListTotal(context.Background(), "2026-08-24", "2026-08-24", 20) + return err + }, + }, + { + name: "列表", path: "/am/stock/list", data: map[string]any{"total": 1, "list": []map[string]any{{"id": 1, "code": "ORDER"}}}, + invoke: func(client *Client) error { + _, _, err := client.ListPage(context.Background(), "2026-08-24", "2026-08-24", 0, 1, 20) + return err + }, + }, + { + name: "明细", path: "/am/stock/detail/listByStock", data: map[string]any{"list": []map[string]any{{"id": 1, "code": "ORDER", "details": []any{}}}}, + invoke: func(client *Client) error { + _, err := client.DetailListByStock(context.Background(), []int64{1}) + return err + }, + }, + } { + t.Run(test.name, func(t *testing.T) { + var requests atomic.Int32 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != test.path { + t.Fatalf("path=%s want=%s", r.URL.Path, test.path) + } + if requests.Add(1) < 3 { + w.WriteHeader(http.StatusBadGateway) + return + } + w.Write(envelopeBody(t, true, "ok", test.data, nil)) + })) + defer server.Close() + client, _ := New(server.URL) + client.readRetryDelays = []time.Duration{0, 0} + if err := test.invoke(client); err != nil { + t.Fatalf("第三次请求应恢复成功: %v", err) + } + if requests.Load() != 3 { + t.Fatalf("requests=%d want=3", requests.Load()) + } + }) + } +} + +func TestClient_只读货运接口达到重试上限后返回最后错误(t *testing.T) { + var requests atomic.Int32 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + requests.Add(1) + w.WriteHeader(http.StatusServiceUnavailable) + })) + defer server.Close() + client, _ := New(server.URL) + client.readRetryDelays = []time.Duration{0, 0} + _, _, err := client.ListPage(context.Background(), "2026-08-24", "2026-08-24", 440, 23, 20) + if err == nil || !strings.Contains(err.Error(), "返回 503") { + t.Fatalf("应返回最后一次 503 错误,实际: %v", err) + } + if requests.Load() != 3 { + t.Fatalf("requests=%d want=3", requests.Load()) + } +} + +func TestClient_只读货运列表响应头超时后可以恢复(t *testing.T) { + var requests atomic.Int32 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + attempt := requests.Add(1) + if attempt < 3 { + time.Sleep(30 * time.Millisecond) + return + } + w.Write(envelopeBody(t, true, "ok", map[string]any{ + "total": 1, "list": []map[string]any{{"id": 1, "code": "ORDER"}}, + }, nil)) + })) + defer server.Close() + client, _ := New(server.URL) + client.http.Timeout = 10 * time.Millisecond + client.readRetryDelays = []time.Duration{0, 0} + rows, _, err := client.ListPage(context.Background(), "2026-08-24", "2026-08-24", 0, 1, 20) + if err != nil || len(rows) != 1 { + t.Fatalf("第三次请求应在响应头超时后恢复,rows=%v err=%v", rows, err) + } + if requests.Load() != 3 { + t.Fatalf("requests=%d want=3", requests.Load()) + } +} + +func TestClient_只读货运接口明确失败不重试(t *testing.T) { + for _, test := range []struct { + name string + code int + body []byte + }{ + {"会话失效", http.StatusUnauthorized, nil}, + {"业务失败", http.StatusOK, envelopeBody(t, false, "查询条件错误", nil, 1)}, + } { + t.Run(test.name, func(t *testing.T) { + var requests atomic.Int32 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + requests.Add(1) + w.WriteHeader(test.code) + w.Write(test.body) + })) + defer server.Close() + client, _ := New(server.URL) + client.readRetryDelays = []time.Duration{0, 0} + _, err := client.ListTotal(context.Background(), "2026-08-24", "2026-08-24", 20) + if err == nil || requests.Load() != 1 { + t.Fatalf("err=%v requests=%d", err, requests.Load()) + } + }) + } +} + +func TestClient_只读货运接口等待重试时响应Context取消(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + var requests atomic.Int32 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + requests.Add(1) + cancel() + w.WriteHeader(http.StatusBadGateway) + })) + defer server.Close() + client, _ := New(server.URL) + client.readRetryDelays = []time.Duration{time.Hour, time.Hour} + started := time.Now() + _, err := client.ListTotal(ctx, "2026-08-24", "2026-08-24", 20) + if !errors.Is(err, context.Canceled) || time.Since(started) > time.Second { + t.Fatalf("取消后应立即停止,err=%v elapsed=%v", err, time.Since(started)) + } + if requests.Load() != 1 { + t.Fatalf("取消后不得继续请求,requests=%d", requests.Load()) + } +} + func TestClient_ListByOrderNumber_使用Allcode精确查询(t *testing.T) { const orderNumber = "260812711E49CU" var paths []string