fix: 避开顺运宝高 offset 尾页 (#302)

This commit is contained in:
chengma
2026-08-24 22:30:24 +08:00
parent f323dda49f
commit 68ff67a652
5 changed files with 415 additions and 27 deletions
+70 -12
View File
@@ -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
+150 -2
View File
@@ -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,
+130 -4
View File
@@ -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
+44
View File
@@ -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) {
+21 -9
View File
@@ -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 后只准重新读取核对,