fix: 增强顺运宝同步完整性保护 (#58)

This commit is contained in:
chengma
2026-08-09 17:17:42 +08:00
parent 17b8f53113
commit 4b5cd13807
8 changed files with 448 additions and 77 deletions
+239 -9
View File
@@ -92,6 +92,8 @@ func TestNewSybSyncOptions_指定日期校验(t *testing.T) {
}{
{name: "两端都空是自动增量"},
{name: "合法闭区间", from: "2026-08-01", to: "2026-08-10"},
{name: "三十一天合法", from: "2026-07-11", to: "2026-08-10"},
{name: "超过三十一天", from: "2026-07-10", to: "2026-08-10", wantErr: "最多选择 31 天"},
{name: "只填开始", from: "2026-08-01", wantErr: "同时填写"},
{name: "开始格式错误", from: "2026/08/01", to: "2026-08-02", wantErr: "开始日期格式"},
{name: "结束格式错误", from: "2026-08-01", to: "2026/08/02", wantErr: "结束日期格式"},
@@ -157,6 +159,7 @@ type fakeStock struct {
ID int64
Code string
ShopName string
Created string
Receiver string // 用来验证个人信息确实没有落库
Details []fakeDetail
}
@@ -169,18 +172,30 @@ type fakeStock struct {
func fakeSybServer(t *testing.T, stocks []fakeStock, failListPageIndex int) *httptest.Server {
t.Helper()
byID := map[int64]fakeStock{}
for _, s := range stocks {
for i := range stocks {
if stocks[i].Created == "" {
stocks[i].Created = "2026-07-28"
}
s := stocks[i]
byID[s.ID] = s
}
return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/am/stock/listTotal":
writeEnvelope(t, w, true, "ok", len(stocks), nil)
var body map[string]any
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
t.Fatalf("解析 listTotal 请求失败: %v", err)
}
filtered := filterFakeStocksByRequest(t, stocks, body)
writeEnvelope(t, w, true, "ok", len(filtered), nil)
case "/am/stock/list":
var body map[string]any
json.NewDecoder(r.Body).Decode(&body)
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
t.Fatalf("解析 list 请求失败: %v", err)
}
filtered := filterFakeStocksByRequest(t, stocks, body)
pageIndex := int(body["pageIndex"].(float64))
length := int(body["length"].(float64))
start := int(body["start"].(float64))
@@ -191,19 +206,19 @@ func fakeSybServer(t *testing.T, stocks []fakeStock, failListPageIndex int) *htt
}
end := start + length
if end > len(stocks) {
end = len(stocks)
if end > len(filtered) {
end = len(filtered)
}
var list []map[string]any
if start < len(stocks) {
for _, s := range stocks[start:end] {
if start < len(filtered) {
for _, s := range filtered[start:end] {
list = append(list, map[string]any{
"id": s.ID, "code": s.Code, "shopName": s.ShopName,
"receiver": s.Receiver, "orderStatus": "待出货",
})
}
}
writeEnvelope(t, w, true, "ok", map[string]any{"list": list}, nil)
writeEnvelope(t, w, true, "ok", map[string]any{"list": list, "total": len(filtered)}, nil)
case "/am/stock/detail/listByStock":
var body struct {
@@ -238,6 +253,60 @@ func fakeSybServer(t *testing.T, stocks []fakeStock, failListPageIndex int) *htt
}))
}
func filterFakeStocksByRequest(t *testing.T, stocks []fakeStock, body map[string]any) []fakeStock {
t.Helper()
queries, ok := body["queries"].([]any)
if !ok || len(queries) == 0 {
t.Fatalf("同步请求缺少 queries: %#v", body["queries"])
}
query, ok := queries[0].(map[string]any)
if !ok {
t.Fatalf("同步请求 queries[0] 格式错误: %#v", queries[0])
}
rangeText, ok := query["dvalue"].(string)
if !ok {
t.Fatalf("同步请求缺少日期范围 dvalue: %#v", query["dvalue"])
}
parts := strings.SplitN(rangeText, ",", 2)
if len(parts) != 2 {
t.Fatalf("同步请求日期范围格式错误: %q", rangeText)
}
filtered := make([]fakeStock, 0, len(stocks))
for _, stock := range stocks {
if stock.Created >= parts[0] && stock.Created <= parts[1] {
filtered = append(filtered, stock)
}
}
return filtered
}
type integrityServerData struct {
total int
responseTotal int
list []map[string]any
details []map[string]any
}
func fakeIntegritySybServer(t *testing.T, data integrityServerData) *httptest.Server {
t.Helper()
return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/am/stock/listTotal":
writeEnvelope(t, w, true, "ok", data.total, nil)
case "/am/stock/list":
writeEnvelope(t, w, true, "ok", map[string]any{
"list": data.list, "total": data.responseTotal,
}, nil)
case "/am/stock/detail/listByStock":
writeEnvelope(t, w, true, "ok", map[string]any{"list": data.details}, nil)
default:
t.Errorf("测试假服务端没有实现这个路径: %s", r.URL.Path)
w.WriteHeader(http.StatusNotFound)
}
}))
}
func writeEnvelope(t *testing.T, w http.ResponseWriter, status bool, msg string, data any, code any) {
t.Helper()
b, err := json.Marshal(map[string]any{"status": status, "msg": msg, "data": data, "code": code})
@@ -297,7 +366,10 @@ func TestRunSybSync_已有的ShopeeSKUID同步后仍在(t *testing.T) {
}
// 再同步一次(模拟顺运宝那边这张单信息有更新)。
report2 := RunSybSync(context.Background(), db, client, cfg, now.Add(time.Hour))
report2 := RunSybSyncWithOptions(context.Background(), db, client, cfg, now.Add(time.Hour), SybSyncOptions{
From: "2026-07-28",
To: "2026-07-28",
})
if report2.Err != nil {
t.Fatalf("二次同步失败: %v", report2.Err)
}
@@ -521,6 +593,164 @@ func TestRunSybSync_超过最大条数报错不硬拉(t *testing.T) {
}
}
func TestRunSybSync_全范围容量预检通过前不拉列表和明细(t *testing.T) {
listCalls := 0
detailCalls := 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":
listCalls++
writeEnvelope(t, w, true, "ok", map[string]any{"list": []any{}, "total": 2}, nil)
case "/am/stock/detail/listByStock":
detailCalls++
writeEnvelope(t, w, true, "ok", map[string]any{"list": []any{}}, nil)
default:
w.WriteHeader(http.StatusNotFound)
}
}))
defer srv.Close()
db := newSyncTestDB(t)
client, _ := syb.New(srv.URL)
report := RunSybSyncWithOptions(context.Background(), db, client,
config.SybConfig{BaseURL: srv.URL, PageSize: 20, MaxMatches: 3, SyncFrom: "2026-07-27"},
time.Date(2026, 7, 28, 12, 0, 0, 0, time.UTC),
SybSyncOptions{From: "2026-07-27", To: "2026-07-28"})
if report.Err == nil || !strings.Contains(report.Err.Error(), "超过单次同步上限") {
t.Fatalf("两天合计 4 张超过上限 3 应该失败,实际: %v", report.Err)
}
if listCalls != 0 || detailCalls != 0 {
t.Fatalf("容量预检完成前不应拉列表或明细: list=%d detail=%d", listCalls, detailCalls)
}
if _, found, _ := repository.GetSybLastSyncedAt(db); found {
t.Fatal("容量预检失败不得推进游标")
}
}
func TestRunSybSync_默认上限允许同步超过五百张(t *testing.T) {
const stockCount = 501
stocks := make([]fakeStock, 0, stockCount)
for i := 1; i <= stockCount; i++ {
id := int64(i)
stocks = append(stocks, fakeStock{
ID: id, Code: "ORDER", Details: []fakeDetail{{
ID: id, ProductID: 100000 + id, ProductQty: 1, ProductPrice: 1,
}},
})
}
srv := fakeSybServer(t, stocks, 0)
defer srv.Close()
db := newSyncTestDB(t)
client, _ := syb.New(srv.URL)
report := RunSybSyncWithOptions(context.Background(), db, client,
config.SybConfig{BaseURL: srv.URL, PageSize: 20, SyncFrom: "2026-07-28"},
time.Date(2026, 7, 28, 12, 0, 0, 0, time.UTC),
SybSyncOptions{From: "2026-07-28", To: "2026-07-28"})
if report.Err != nil {
t.Fatalf("默认上限应该允许 501 张货运单正常同步,实际失败: %v", report.Err)
}
if report.StockCount != stockCount || report.Created != stockCount {
t.Fatalf("同步统计错误: StockCount=%d Created=%d", report.StockCount, report.Created)
}
}
func TestRunSybSync_跨日范围按天同步并汇总(t *testing.T) {
srv := fakeSybServer(t, []fakeStock{
{ID: 1, Code: "DAY-1", Created: "2026-07-27", Details: []fakeDetail{{ID: 11, ProductID: 111, ProductQty: 1}}},
{ID: 2, Code: "DAY-2", Created: "2026-07-28", Details: []fakeDetail{{ID: 22, ProductID: 222, ProductQty: 1}}},
}, 0)
defer srv.Close()
db := newSyncTestDB(t)
client, _ := syb.New(srv.URL)
report := RunSybSyncWithOptions(context.Background(), db, client,
config.SybConfig{BaseURL: srv.URL, PageSize: 20, MaxMatches: 100, SyncFrom: "2026-07-27"},
time.Date(2026, 7, 28, 12, 0, 0, 0, time.UTC),
SybSyncOptions{From: "2026-07-27", To: "2026-07-28"})
if report.Err != nil {
t.Fatalf("跨日同步失败: %v", report.Err)
}
if report.StockCount != 2 || report.Created != 2 {
t.Fatalf("跨日汇总错误: StockCount=%d Created=%d", report.StockCount, report.Created)
}
}
func TestValidateDetailBatch_必须与请求货运单一一对应(t *testing.T) {
cases := []struct {
name string
details []syb.StockDetail
wantErr string
}{
{name: "完整", details: []syb.StockDetail{
{ID: 1, Details: []syb.DetailItem{{ID: 11}}},
{ID: 2, Details: []syb.DetailItem{{ID: 22}}},
}},
{name: "缺失", details: []syb.StockDetail{{ID: 1, Details: []syb.DetailItem{{ID: 11}}}}, wantErr: "缺少"},
{name: "重复", details: []syb.StockDetail{
{ID: 1, Details: []syb.DetailItem{{ID: 11}}},
{ID: 1, Details: []syb.DetailItem{{ID: 12}}},
}, wantErr: "重复"},
{name: "意外ID", details: []syb.StockDetail{
{ID: 1, Details: []syb.DetailItem{{ID: 11}}},
{ID: 3, Details: []syb.DetailItem{{ID: 33}}},
}, wantErr: "未请求"},
{name: "空明细", details: []syb.StockDetail{
{ID: 1, Details: []syb.DetailItem{{ID: 11}}}, {ID: 2},
}, wantErr: "没有返回商品明细"},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
err := validateDetailBatch([]int64{1, 2}, tc.details)
if tc.wantErr == "" && err != nil {
t.Fatalf("完整响应不应该报错: %v", err)
}
if tc.wantErr != "" && (err == nil || !strings.Contains(err.Error(), tc.wantErr)) {
t.Fatalf("错误应包含 %q,实际 %v", tc.wantErr, err)
}
})
}
}
func TestRunSybSync_列表或明细不完整时不推进游标(t *testing.T) {
completeList := []map[string]any{{"id": 1, "code": "A"}, {"id": 2, "code": "B"}}
completeDetails := []map[string]any{
{"id": 1, "code": "A", "details": []map[string]any{{"id": 11, "productId": 111, "productQty": 1}}},
{"id": 2, "code": "B", "details": []map[string]any{{"id": 22, "productId": 222, "productQty": 1}}},
}
cases := []struct {
name string
data integrityServerData
wantErr string
}{
{name: "分页期间总数变化", data: integrityServerData{total: 2, responseTotal: 3, list: completeList}, wantErr: "分页期间"},
{name: "列表存在重复ID", data: integrityServerData{total: 2, responseTotal: 2,
list: []map[string]any{{"id": 1, "code": "A"}, {"id": 1, "code": "A"}}}, wantErr: "列表不完整"},
{name: "明细缺少货运单", data: integrityServerData{total: 2, responseTotal: 2,
list: completeList, details: completeDetails[:1]}, wantErr: "明细不完整"},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
srv := fakeIntegritySybServer(t, tc.data)
defer srv.Close()
db := newSyncTestDB(t)
client, _ := syb.New(srv.URL)
report := RunSybSyncWithOptions(context.Background(), db, client,
config.SybConfig{BaseURL: srv.URL, PageSize: 20, MaxMatches: 100, SyncFrom: "2026-07-28"},
time.Date(2026, 7, 28, 12, 0, 0, 0, time.UTC),
SybSyncOptions{From: "2026-07-28", To: "2026-07-28"})
if report.Err == nil || !strings.Contains(report.Err.Error(), tc.wantErr) {
t.Fatalf("错误应包含 %q,实际 %v", tc.wantErr, report.Err)
}
if _, found, err := repository.GetSybLastSyncedAt(db); err != nil || found {
t.Fatalf("不完整响应不得推进游标: found=%v err=%v", found, err)
}
})
}
}
func TestRunSybSync_一张货运单多个商品各落一行(t *testing.T) {
srv := fakeSybServer(t, []fakeStock{
{ID: 1, Code: "A", Details: []fakeDetail{