diff --git a/admin/service/syb.go b/admin/service/syb.go index 5ba074b..d252024 100644 --- a/admin/service/syb.go +++ b/admin/service/syb.go @@ -17,6 +17,7 @@ import ( "encoding/json" "errors" "fmt" + "log" "math" "sort" "strconv" @@ -472,7 +473,7 @@ type sybDailyListResult struct { observedTotal int } -// sybListDriftError 表示请求本身成功,但分页期间的列表没有形成稳定快照。 +// sybListDriftError 表示请求本身成功,但列表读取期间没有形成稳定快照。 // 只有今天允许有限重试;历史日期遇到它仍然立即失败。 type sybListDriftError struct { message string @@ -480,10 +481,70 @@ type sybListDriftError struct { func (e *sybListDriftError) Error() string { return e.message } -// loadSybDailyList 拉取一天的全部列表,并核对页长、分页前后总数及唯一 ID。 +// loadSybDailyList 拉取一天的全部列表,并核对响应条数、读取前后总数及唯一 ID。 +// 正常路径一次请求当天总数,避开顺运宝已实测不稳定的高 offset 尾页;只有 +// 整批请求明确耗尽三次只读临时故障重试时,才回退原有小页顺序读取。 // 发生快照漂移时仍返回本次已经取得的合法 ID,供今天最后一次尝试在完整拉取 // 明细后安全 upsert;调用方不得因此把本次同步标记为成功或推进游标。 func loadSybDailyList(ctx context.Context, client *syb.Client, date string, pageSize, expectedTotal int) (sybDailyListResult, error) { + if expectedTotal <= 0 { + return finishSybDailyList(ctx, client, date, pageSize, expectedTotal, + newSybDailyListResult(expectedTotal)) + } + + result := newSybDailyListResult(expectedTotal) + rows, pageCount, err := client.ListPage(ctx, date, date, 0, 1, expectedTotal) + if err == nil { + appendSybListRows(&result, rows) + if pageCount != expectedTotal || len(rows) != expectedTotal { + afterTotal, totalErr := client.ListTotal(ctx, date, date, pageSize) + if totalErr != nil { + return result, fmt.Errorf("整批列表异常后重新查询 %s 货运单总数失败: %w", date, totalErr) + } + result.observedTotal = afterTotal + return result, &sybListDriftError{message: fmt.Sprintf( + "%s 货运单整批列表不完整:预期 %d 行,实际 %d 行", + date, expectedTotal, len(rows))} + } + return finishSybDailyList(ctx, client, date, pageSize, expectedTotal, result) + } + if !errors.Is(err, syb.ErrReadRetryExhausted) { + return result, fmt.Errorf("整批拉取 %s 货运单列表失败(本次同步整体作废,"+ + "下次会从同一个起始日期重新拉,靠 upsert 幂等不会重复计数): %w", date, err) + } + + log.Printf("顺运宝整批列表读取耗尽临时故障重试,回退顺序分页 date=%s expected_total=%d page_size=%d", + date, expectedTotal, pageSize) + fallbackResult, fallbackErr := loadSybDailyListByPages(ctx, client, date, pageSize, expectedTotal) + if fallbackErr != nil { + return fallbackResult, fmt.Errorf("整批读取 %s 货运单列表耗尽临时故障重试后回退分页仍失败: %w", + date, fallbackErr) + } + return fallbackResult, nil +} + +func newSybDailyListResult(expectedTotal int) sybDailyListResult { + capacity := max(expectedTotal, 0) + return sybDailyListResult{ + stockByID: make(map[int64]syb.StockRow, capacity), + orderedIDs: make([]int64, 0, capacity), + observedTotal: expectedTotal, + } +} + +func appendSybListRows(result *sybDailyListResult, rows []syb.StockRow) { + for _, row := range rows { + if _, duplicate := result.stockByID[row.ID]; duplicate { + continue + } + result.stockByID[row.ID] = row + result.orderedIDs = append(result.orderedIDs, row.ID) + } +} + +// loadSybDailyListByPages 保留原有 page_size 顺序分页,作为整批读取明确耗尽 +// 临时故障重试后的兼容回退。这里仍逐页核对条数,不能把回退当成放宽门禁。 +func loadSybDailyListByPages(ctx context.Context, client *syb.Client, date string, pageSize, expectedTotal int) (sybDailyListResult, error) { result := sybDailyListResult{ stockByID: make(map[int64]syb.StockRow, expectedTotal), orderedIDs: make([]int64, 0, expectedTotal), @@ -497,13 +558,7 @@ func loadSybDailyList(ctx context.Context, client *syb.Client, date string, page "下次会从同一个起始日期重新拉,靠 upsert 幂等不会重复计数): %w", date, pageIndex, len(result.orderedIDs), expectedTotal, err) } - for _, row := range rows { - if _, duplicate := result.stockByID[row.ID]; duplicate { - continue - } - result.stockByID[row.ID] = row - result.orderedIDs = append(result.orderedIDs, row.ID) - } + appendSybListRows(&result, rows) expectedPageCount := min(pageSize, expectedTotal-start) if pageCount != expectedPageCount || len(rows) != expectedPageCount { @@ -517,19 +572,22 @@ func loadSybDailyList(ctx context.Context, client *syb.Client, date string, page date, pageIndex, expectedPageCount, len(rows))} } } + return finishSybDailyList(ctx, client, date, pageSize, expectedTotal, result) +} +func finishSybDailyList(ctx context.Context, client *syb.Client, date string, pageSize, expectedTotal int, result sybDailyListResult) (sybDailyListResult, error) { afterTotal, err := client.ListTotal(ctx, date, date, pageSize) if err != nil { - return result, fmt.Errorf("分页后重新查询 %s 货运单总数失败: %w", date, err) + return result, fmt.Errorf("列表读取后重新查询 %s 货运单总数失败: %w", date, err) } result.observedTotal = afterTotal if afterTotal != expectedTotal { return result, &sybListDriftError{message: fmt.Sprintf( - "%s 货运单总数在分页期间从 %d 变为 %d", date, expectedTotal, afterTotal)} + "%s 货运单总数在列表读取期间从 %d 变为 %d", date, expectedTotal, afterTotal)} } if len(result.orderedIDs) != expectedTotal { return result, &sybListDriftError{message: fmt.Sprintf( - "%s 货运单列表不完整:预期 %d 张,分页后只有 %d 个唯一 ID", + "%s 货运单列表不完整:预期 %d 张,读取后只有 %d 个唯一 ID", date, expectedTotal, len(result.orderedIDs))} } return result, nil diff --git a/admin/service/syb_test.go b/admin/service/syb_test.go index ca7639f..c8be849 100644 --- a/admin/service/syb_test.go +++ b/admin/service/syb_test.go @@ -228,6 +228,12 @@ func fakeSybServer(t *testing.T, stocks []fakeStock, failListPageIndex int) *htt length := int(body["length"].(float64)) start := int(body["start"].(float64)) + // 非零失败页只供“中途失败不推进游标”测试:先让整批请求 + // 以 5xx 耗尽三次,迫使代码进入原分页,再在指定页返回业务失败。 + if failListPageIndex > 0 && pageIndex == 1 && length == len(filtered) && length > 1 { + w.WriteHeader(http.StatusServiceUnavailable) + return + } if failListPageIndex > 0 && pageIndex == failListPageIndex { writeEnvelope(t, w, false, "模拟的服务端故障", nil, "500") return @@ -310,6 +316,148 @@ func filterFakeStocksByRequest(t *testing.T, stocks []fakeStock, body map[string return filtered } +func TestLoadSybDailyList_正常路径一次读取当天全部货运单(t *testing.T) { + for _, total := range []int{484, 761, 894} { + t.Run(fmt.Sprintf("total_%d", total), func(t *testing.T) { + listCalls := 0 + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/am/stock/listTotal": + writeEnvelope(t, w, true, "ok", total, nil) + case "/am/stock/list": + listCalls++ + var body map[string]any + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + t.Fatalf("解析整批列表请求失败: %v", err) + } + if body["start"] != float64(0) || body["pageIndex"] != float64(1) || body["length"] != float64(total) { + t.Fatalf("整批列表请求形状错误: %#v", body) + } + rows := make([]map[string]any, 0, total) + for id := 1; id <= total; id++ { + rows = append(rows, map[string]any{"id": id, "code": fmt.Sprintf("ORDER-%d", id)}) + } + writeEnvelope(t, w, true, "ok", map[string]any{"list": rows, "total": total}, nil) + default: + w.WriteHeader(http.StatusNotFound) + } + })) + defer srv.Close() + + client, _ := syb.New(srv.URL) + result, err := loadSybDailyList(context.Background(), client, "2026-08-24", 20, total) + if err != nil { + t.Fatalf("整批读取 %d 张失败: %v", total, err) + } + if listCalls != 1 || len(result.orderedIDs) != total || len(result.stockByID) != total || result.observedTotal != total { + t.Fatalf("整批读取结果错误: calls=%d ids=%d map=%d observed=%d", + listCalls, len(result.orderedIDs), len(result.stockByID), result.observedTotal) + } + }) + } +} + +func TestLoadSybDailyList_整批临时故障耗尽后回退原分页(t *testing.T) { + primaryCalls, fallbackCalls := 0, 0 + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/am/stock/listTotal": + writeEnvelope(t, w, true, "ok", 2, nil) + case "/am/stock/list": + var body map[string]any + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + t.Fatalf("解析列表请求失败: %v", err) + } + length := int(body["length"].(float64)) + start := int(body["start"].(float64)) + if length == 2 { + primaryCalls++ + w.WriteHeader(http.StatusServiceUnavailable) + return + } + fallbackCalls++ + writeEnvelope(t, w, true, "ok", map[string]any{ + "list": []map[string]any{{"id": start + 1, "code": fmt.Sprintf("ORDER-%d", start+1)}}, + "total": 1, + }, nil) + default: + w.WriteHeader(http.StatusNotFound) + } + })) + defer srv.Close() + + client, _ := syb.New(srv.URL) + result, err := loadSybDailyList(context.Background(), client, "2026-08-24", 1, 2) + if err != nil { + t.Fatalf("回退分页后应成功: %v", err) + } + if primaryCalls != 3 || fallbackCalls != 2 || len(result.orderedIDs) != 2 { + t.Fatalf("回退次数或结果错误: primary=%d fallback=%d ids=%v", + primaryCalls, fallbackCalls, result.orderedIDs) + } +} + +func TestLoadSybDailyList_业务错误不回退分页(t *testing.T) { + listCalls := 0 + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/am/stock/list": + listCalls++ + writeEnvelope(t, w, false, "参数错误", nil, 1) + default: + writeEnvelope(t, w, true, "ok", 2, nil) + } + })) + defer srv.Close() + + client, _ := syb.New(srv.URL) + if _, err := loadSybDailyList(context.Background(), client, "2026-08-24", 1, 2); err == nil { + t.Fatal("业务错误必须返回失败") + } + if listCalls != 1 { + t.Fatalf("业务错误不得触发整批重试或分页回退,实际请求 %d 次", listCalls) + } +} + +func TestLoadSybDailyList_整批读取仍保留完整性门禁(t *testing.T) { + cases := []struct { + name string + rows []map[string]any + afterTotal int + wantErr string + }{ + {name: "响应短缺", rows: []map[string]any{{"id": 1, "code": "A"}}, afterTotal: 2, wantErr: "整批列表不完整"}, + {name: "重复ID", rows: []map[string]any{{"id": 1, "code": "A"}, {"id": 1, "code": "A"}}, afterTotal: 2, wantErr: "唯一 ID"}, + {name: "总数漂移", rows: []map[string]any{{"id": 1, "code": "A"}, {"id": 2, "code": "B"}}, afterTotal: 3, wantErr: "列表读取期间"}, + } + for _, test := range cases { + t.Run(test.name, func(t *testing.T) { + totalCalls := 0 + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/am/stock/list": + writeEnvelope(t, w, true, "ok", map[string]any{"list": test.rows, "total": len(test.rows)}, nil) + case "/am/stock/listTotal": + totalCalls++ + writeEnvelope(t, w, true, "ok", test.afterTotal, nil) + default: + w.WriteHeader(http.StatusNotFound) + } + })) + defer srv.Close() + + client, _ := syb.New(srv.URL) + _, err := loadSybDailyList(context.Background(), client, "2026-08-24", 20, 2) + if err == nil || !strings.Contains(err.Error(), test.wantErr) { + t.Fatalf("错误应包含 %q,实际 %v", test.wantErr, err) + } + if totalCalls != 1 { + t.Fatalf("异常后必须复核一次总数,实际 %d 次", totalCalls) + } + }) + } +} + type integrityServerData struct { total int afterTotal int @@ -1087,9 +1235,9 @@ func TestRunSybSync_列表或明细不完整时不推进游标(t *testing.T) { }{ {name: "页内报告条数与实际列表不符", data: integrityServerData{total: 2, pageCount: 1, list: completeList}, wantErr: "当前页条数"}, {name: "当前页短于预期", data: integrityServerData{total: 2, pageCount: 1, - list: []map[string]any{{"id": 1, "code": "A"}}}, wantErr: "第 1 页不完整"}, + list: []map[string]any{{"id": 1, "code": "A"}}}, wantErr: "整批列表不完整"}, {name: "分页前后总数变化", data: integrityServerData{total: 2, afterTotal: 3, totalChanges: true, - pageCount: 2, list: completeList}, wantErr: "分页期间"}, + pageCount: 2, list: completeList}, wantErr: "列表读取期间"}, {name: "列表存在重复ID", data: integrityServerData{total: 2, pageCount: 2, list: []map[string]any{{"id": 1, "code": "A"}, {"id": 1, "code": "A"}}}, wantErr: "列表不完整"}, {name: "明细缺少货运单", data: integrityServerData{total: 2, pageCount: 2, diff --git a/admin/syb/client.go b/admin/syb/client.go index 244e579..b3c1248 100644 --- a/admin/syb/client.go +++ b/admin/syb/client.go @@ -18,6 +18,7 @@ import ( "errors" "fmt" "io" + "log" "net/http" "net/http/cookiejar" "net/url" @@ -42,6 +43,11 @@ var ErrSessionInvalid = errors.New("顺运宝会话未登录或已过期") // 调用方只能重新读取核对,绝不能自动重发同一个写请求。 var ErrWriteResultUnknown = errors.New("顺运宝写入结果未知") +// ErrReadRetryExhausted 表示明确的只读网络/读响应/5xx 故障已经尝试三次。 +// service 只在 errors.Is 命中本错误时切换列表读取策略;会话、业务、格式 +// 和 context 取消错误绝不能借此多发请求。 +var ErrReadRetryExhausted = errors.New("顺运宝只读请求重试已耗尽") + const ( defaultRequestTimeout = 60 * time.Second defaultReadOnlyMaxAttempts = 3 @@ -53,6 +59,7 @@ const ( type requestOutcomeUnknownError struct { err error retryableRead bool + failureKind string } func (e requestOutcomeUnknownError) Error() string { return e.err.Error() } @@ -73,6 +80,23 @@ type Client struct { // 把退避缩短到 0,避免单元测试真的等待 1 秒和 2 秒。 readOnlyMaxAttempts int readOnlyRetryDelay func(failedAttempt int) time.Duration + readOnlyRetryLog func(readOnlyRetryLogEntry) +} + +// readOnlyRetryLogEntry 只保留排障需要的安全元数据。DateRange 只有在请求 +// 确认使用 created 日期条件且值是两个 YYYY-MM-DD 时才填写;订单号、Cookie、 +// 请求体和响应业务数据永远不会进入这里。 +type readOnlyRetryLogEntry struct { + Path string + DateRange string + Start int + PageIndex int + Length int + Attempt int + MaxAttempts int + Elapsed time.Duration + FailureKind string + Recovered bool } // New 创建一个新的顺运宝客户端,带一个空的 Cookie Jar。 @@ -97,6 +121,7 @@ func New(baseURL string) (*Client, error) { }, readOnlyMaxAttempts: defaultReadOnlyMaxAttempts, readOnlyRetryDelay: readOnlyRetryDelay, + readOnlyRetryLog: logReadOnlyRetry, }, nil } @@ -209,6 +234,7 @@ func (c *Client) do(ctx context.Context, method, path string, query url.Values, return nil, requestOutcomeUnknownError{ err: fmt.Errorf("请求顺运宝接口 %s 失败(网络问题,不代表未登录): %w", path, err), retryableRead: true, + failureKind: "network", } } defer resp.Body.Close() @@ -218,6 +244,7 @@ func (c *Client) do(ctx context.Context, method, path string, query url.Values, return nil, requestOutcomeUnknownError{ err: fmt.Errorf("读取顺运宝接口 %s 响应失败: %w", path, err), retryableRead: true, + failureKind: "response_read", } } @@ -228,6 +255,7 @@ func (c *Client) do(ctx context.Context, method, path string, query url.Values, return nil, requestOutcomeUnknownError{ err: fmt.Errorf("顺运宝接口 %s 返回 %d(服务端故障,不代表未登录)", path, resp.StatusCode), retryableRead: true, + failureKind: "http_5xx", } } if resp.StatusCode != http.StatusOK { @@ -262,22 +290,48 @@ func (c *Client) doReadOnly(ctx context.Context, method, path string, query url. if delayFor == nil { delayFor = readOnlyRetryDelay } + logRetry := c.readOnlyRetryLog + if logRetry == nil { + logRetry = logReadOnlyRetry + } + requestMeta := readOnlyRequestMetadata(path, body) for attempt := 1; attempt <= maxAttempts; attempt++ { + started := time.Now() data, err := c.do(ctx, method, path, query, body) + elapsed := time.Since(started) if err == nil { + if attempt > 1 { + entry := requestMeta + entry.Attempt = attempt + entry.MaxAttempts = maxAttempts + entry.Elapsed = elapsed + entry.FailureKind = "recovered" + entry.Recovered = true + logRetry(entry) + } return data, nil } + + var unknown requestOutcomeUnknownError + retryable := errors.As(err, &unknown) && unknown.retryableRead + if retryable { + entry := requestMeta + entry.Attempt = attempt + entry.MaxAttempts = maxAttempts + entry.Elapsed = elapsed + entry.FailureKind = classifyReadOnlyFailure(err, unknown.failureKind) + logRetry(entry) + } if ctx.Err() != nil { return nil, fmt.Errorf("顺运宝只读接口 %s 已取消: %w", path, ctx.Err()) } - - var unknown requestOutcomeUnknownError - if !errors.As(err, &unknown) || !unknown.retryableRead { + if !retryable { return nil, err } if attempt == maxAttempts { - return nil, fmt.Errorf("顺运宝只读接口 %s 连续 %d 次请求失败: %w", path, maxAttempts, err) + return nil, fmt.Errorf("%w: 顺运宝只读接口 %s 连续 %d 次请求失败: %w", + ErrReadRetryExhausted, path, maxAttempts, err) } if err := waitReadOnlyRetry(ctx, delayFor(attempt)); err != nil { return nil, fmt.Errorf("等待重试顺运宝只读接口 %s 时取消: %w", path, err) @@ -286,6 +340,78 @@ func (c *Client) doReadOnly(ctx context.Context, method, path string, query url. return nil, fmt.Errorf("顺运宝只读接口 %s 重试状态异常", path) } +func classifyReadOnlyFailure(err error, fallback string) string { + if errors.Is(err, context.DeadlineExceeded) { + return "timeout" + } + if errors.Is(err, context.Canceled) { + return "canceled" + } + if fallback != "" { + return fallback + } + return "network" +} + +func logReadOnlyRetry(entry readOnlyRetryLogEntry) { + status := "失败" + if entry.Recovered { + status = "恢复" + } + dateRange := entry.DateRange + if dateRange == "" { + dateRange = "-" + } + log.Printf("顺运宝只读请求%s path=%s date_range=%s start=%d page_index=%d length=%d "+ + "attempt=%d/%d elapsed_ms=%d kind=%s", + status, entry.Path, dateRange, entry.Start, entry.PageIndex, entry.Length, + entry.Attempt, entry.MaxAttempts, entry.Elapsed.Milliseconds(), entry.FailureKind) +} + +func readOnlyRequestMetadata(path string, body any) readOnlyRetryLogEntry { + entry := readOnlyRetryLogEntry{Path: path} + payload, ok := body.(map[string]any) + if !ok { + return entry + } + entry.Start, _ = retryMetadataInt(payload["start"]) + entry.PageIndex, _ = retryMetadataInt(payload["pageIndex"]) + entry.Length, _ = retryMetadataInt(payload["length"]) + entry.DateRange = safeCreatedDateRange(payload["queries"]) + return entry +} + +func retryMetadataInt(value any) (int, bool) { + switch n := value.(type) { + case int: + return n, true + case int64: + return int(n), true + case float64: + return int(n), true + default: + return 0, false + } +} + +func safeCreatedDateRange(value any) string { + queries, ok := value.([]map[string]any) + if !ok || len(queries) != 1 || queries[0]["colName"] != "created" { + return "" + } + dvalue, _ := queries[0]["dvalue"].(string) + parts := strings.Split(dvalue, ",") + if len(parts) != 2 { + return "" + } + for _, part := range parts { + if _, err := time.Parse("2006-01-02", part); err != nil { + return "" + } + } + return parts[0] + "~" + parts[1] +} + func readOnlyRetryDelay(failedAttempt int) time.Duration { if failedAttempt <= 1 { return time.Second diff --git a/admin/syb/client_test.go b/admin/syb/client_test.go index c31a7b8..d79d6fa 100644 --- a/admin/syb/client_test.go +++ b/admin/syb/client_test.go @@ -449,6 +449,50 @@ func TestClient_只读临时故障最多尝试三次(t *testing.T) { if requests.Load() != 3 { t.Fatalf("只读请求最多应尝试 3 次,实际 %d 次", requests.Load()) } + if !errors.Is(err, ErrReadRetryExhausted) { + t.Fatalf("耗尽三次后必须能用 ErrReadRetryExhausted 分类,实际: %v", err) + } +} + +func TestClient_只读重试日志只包含安全定位字段(t *testing.T) { + var requests atomic.Int32 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if requests.Add(1) == 1 { + w.WriteHeader(http.StatusServiceUnavailable) + return + } + w.Write(envelopeBody(t, true, "ok", map[string]any{ + "total": 1, + "list": []map[string]any{{"id": 1, "code": "ORDER-SECRET"}}, + }, nil)) + })) + defer server.Close() + + client, _ := New(server.URL) + client.readOnlyRetryDelay = func(int) time.Duration { return 0 } + var entries []readOnlyRetryLogEntry + client.readOnlyRetryLog = func(entry readOnlyRetryLogEntry) { + entries = append(entries, entry) + } + rows, _, err := client.ListPage(context.Background(), "2026-08-24", "2026-08-24", 480, 25, 20) + if err != nil || len(rows) != 1 { + t.Fatalf("重试后应恢复成功 rows=%v err=%v", rows, err) + } + if len(entries) != 2 { + t.Fatalf("应记录一次失败和一次恢复,实际 %+v", entries) + } + failure, recovered := entries[0], entries[1] + if failure.Path != "/am/stock/list" || failure.DateRange != "2026-08-24~2026-08-24" || + failure.Start != 480 || failure.PageIndex != 25 || failure.Length != 20 || + failure.Attempt != 1 || failure.MaxAttempts != 3 || failure.FailureKind != "http_5xx" || failure.Recovered { + t.Fatalf("失败日志定位字段不正确: %+v", failure) + } + if !recovered.Recovered || recovered.Attempt != 2 || recovered.FailureKind != "recovered" { + t.Fatalf("恢复日志不正确: %+v", recovered) + } + if meta := readOnlyRequestMetadata("/am/stock/list", orderNumberListPayload("ORDER-SECRET", 0, 1, 20)); meta.DateRange != "" { + t.Fatalf("订单号查询不得被当成安全日期写日志: %+v", meta) + } } func TestClient_只读重试等待支持Context取消(t *testing.T) { diff --git a/docs/admin/08-顺运宝接口.md b/docs/admin/08-顺运宝接口.md index 4ad9154..428648b 100644 --- a/docs/admin/08-顺运宝接口.md +++ b/docs/admin/08-顺运宝接口.md @@ -188,33 +188,45 @@ POST /am/stock/list → data.list 是数组,data.total 是当前页条 ### 4.3 分页 `[必须]` 一个跨日范围要拆成逐日查询。先逐日调用 `listTotal` 做全范围容量 -预检,确认合计不超限后,再按天以 `length = 20` 翻页调用 `list`。明细仍按 -最多 100 个货运单 ID 一批读取。 +预检,确认合计不超限后,再按天优先用 +`start = 0, pageIndex = 1, length = 当天预检总数` 一次调用 `list`。 +生产实测这种低 offset 请求能避开顺运宝不稳定的高 offset 尾页,并显著减少 +列表请求次数。明细仍按最多 100 个货运单 ID 一批读取。 `[必须]` **必须有单次同步的条数上限**,超了报错而不是硬拉。 Admin 默认 `max_matches = 10000`,可以在配置中调整;上限针对整个日期范围的 逐日总数合计,不是每天各算一次。任何一天的列表或明细都不得在容量预检通过前 开始拉取,避免超限后已经产生部分写入。 -`[必须]` 每一页 `list.data.total` 必须等于该页 `list` 数组长度。非最后一页 -必须返回 `length` 条,最后一页必须返回预检总数对应的剩余条数。每天翻页结束后 -再次调用 `listTotal`,前后总数必须一致;全部页去重后的货运单 ID 数还必须等于 -预检总数。历史日期出现前后总数变化、短页、重复 ID 或唯一 ID 不足时立即失败, -不推进游标。 +`[必须]` `list.data.total` 必须等于 `list` 数组长度。整批正常路径必须返回 +预检总数对应的全部行。只有整批请求明确耗尽 3 次临时故障重试时,才回退配置的 +`page_size` 顺序分页;业务、格式、会话或调用方取消错误不得触发回退。分页回退中 +非最后一页必须返回 `page_size` 条,最后一页必须返回预检总数对应的剩余条数。 +列表读取结束后再次调用 `listTotal`,前后总数必须一致;全部结果去重后的货运单 ID +数还必须等于预检总数。历史日期出现前后总数变化、短响应、重复 ID 或唯一 ID 不足 +时立即失败,不推进游标。 今天的货运单会在同步期间持续新增。只有 UTC+8 下的今天发生上述快照漂移时, -允许只重试今天的列表分页,最多 3 次;已经完成的历史日期不得重复拉取,每次尝试 +允许只重试今天的列表快照,最多 3 次;已经完成的历史日期不得重复拉取,每次尝试 也必须使用独立 ID 集合。第三次仍不稳定时,可以对最后一次取得的合法唯一 ID 读取完整明细并按既有 upsert 保存,但本次同步仍记为失败、明确提示当天未形成 稳定快照且不推进游标,下一次继续覆盖今天。任何尝试都不得突破 `max_matches`; 业务错误、非法 ID 或不完整明细不属于可放宽的快照漂移。 -`[必须]` 生产实测顺运宝可能在历史日期的高页码查询中超过 30 秒仍未返回响应头。 +`[必须]` 生产实测顺运宝可能在高 offset 尾页查询中连续 60 秒不返回响应头;固定向前 +重叠 20、100 或 500 条也不能覆盖所有日期,而 `start=0,length=当天总数` 已实测 +能完整返回 761 和 894 个唯一货运单 ID。因此整批低 offset 是正常路径,旧分页仅作 +临时故障重试耗尽后的兼容回退。 + 货运单 `listTotal`、`list` 和 `detail/listByStock` 是只读查询:网络连接/超时、 响应读取失败或 HTTP 5xx 时,只重试当前总数请求、当前页或当前明细批次,最多 3 次,间隔 1 秒、2 秒,单次请求总超时 60 秒。重试必须复用同一个 Cookie Jar 和完全相同的请求参数;调用方取消时立即停止等待。三次仍失败则停止同步且不推进游标。 +每次只读失败和重试恢复必须记录安全诊断字段:日期范围(仅限经过格式校验的 +`created` 日期条件)、接口路径、`start`、`pageIndex`、`length`、尝试次数、耗时和 +错误分类。不得记录 Cookie、账号密码、完整请求体、订单号或响应业务数据。 + `[必须]` 401/403、业务信封失败、非法 JSON、分页/明细格式错误和完整性错误不自动 重试。登录、验证码、会话校验也不使用这套货运查询重试。删除入库码、新增明细、 写入快递单号等真实写请求仍然只允许发送一次;超时或 5xx 后只准重新读取核对,