fix: 增强顺运宝同步完整性保护 (#58)
This commit is contained in:
+239
-9
@@ -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{
|
||||
|
||||
Reference in New Issue
Block a user