fix: 避开顺运宝高 offset 尾页 (#302)
This commit is contained in:
+70
-12
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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
@@ -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 后只准重新读取核对,
|
||||
|
||||
Reference in New Issue
Block a user