From b1629b900a23bb801aa7b0ad55ab491ee9b51bee Mon Sep 17 00:00:00 2001 From: QiuSW <105186638@qq.com> Date: Wed, 23 Sep 2026 15:57:00 +0800 Subject: [PATCH] fix(yeeke): decode string quantities and read yeeke times as Beijing time (#336) The live list returns variationQuantityPurchased as a numeric string ("2"), which failed decoding the whole page. Decode it through a tolerant FlexInt (number, numeric string, null or empty). yeeke times are naive Beijing wall-clock strings; parse them in UTC+8 instead of UTC so stored times are not shifted by eight hours. Adds a decode test built from the HAR field types with fake values. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01NTDbDcwbDw1TSAcE6wfh2F --- server/app/goauto/yeeke/sync.go | 2 +- server/app/goauto/yeekeclient/client.go | 41 ++++++++++++++++---- server/app/goauto/yeekeclient/client_test.go | 40 +++++++++++++++++++ 3 files changed, 74 insertions(+), 9 deletions(-) diff --git a/server/app/goauto/yeeke/sync.go b/server/app/goauto/yeeke/sync.go index d69b5e4..7b34dfa 100644 --- a/server/app/goauto/yeeke/sync.go +++ b/server/app/goauto/yeeke/sync.go @@ -287,7 +287,7 @@ func (s *Service) upsert(ctx context.Context, p yeekeclient.ReturnPackage) (bool if ie != nil && ie != gorm.ErrRecordNotFound { return false, false, ie } - iv := map[string]any{"package_id": row.ID, "external_key": ik, "item_id": external(i.ItemID), "variation_id": external(i.VariationID), "item_name": i.ItemName, "variation_name": i.VariationName, "image": i.Image, "quantity": i.Quantity, "last_synced_at": now, "sync_status": "ok"} + iv := map[string]any{"package_id": row.ID, "external_key": ik, "item_id": external(i.ItemID), "variation_id": external(i.VariationID), "item_name": i.ItemName, "variation_name": i.VariationName, "image": i.Image, "quantity": int64(i.Quantity), "last_synced_at": now, "sync_status": "ok"} if ie == gorm.ErrRecordNotFound { if e = s.db.WithContext(ctx).Create(&models.YeekeReturnItem{PackageID: row.ID, ExternalKey: ik}).Error; e != nil { return false, false, e diff --git a/server/app/goauto/yeekeclient/client.go b/server/app/goauto/yeekeclient/client.go index ab9e64c..3a64fca 100644 --- a/server/app/goauto/yeekeclient/client.go +++ b/server/app/goauto/yeekeclient/client.go @@ -311,7 +311,9 @@ func (t *Timestamp) UnmarshalJSON(b []byte) error { return nil } for _, f := range []string{time.RFC3339, "2006-01-02 15:04:05", "2006-01-02"} { - if x, e := time.ParseInLocation(f, s, time.UTC); e == nil { + // yeeke returns naive Beijing wall-clock times ("2026-09-21 14:13:29"); + // reading them as UTC would shift every time by eight hours. + if x, e := time.ParseInLocation(f, s, beijing); e == nil { t.Time = x return nil } @@ -336,13 +338,36 @@ type ReturnPackage struct { Items []ReturnItem `json:"items"` } type ReturnItem struct { - ID any `json:"id"` - ItemID any `json:"itemId"` - VariationID any `json:"variationId"` - ItemName string `json:"itemName"` - VariationName string `json:"variationName"` - Image string `json:"image"` - Quantity int64 `json:"variationQuantityPurchased"` + ID any `json:"id"` + ItemID any `json:"itemId"` + VariationID any `json:"variationId"` + ItemName string `json:"itemName"` + VariationName string `json:"variationName"` + Image string `json:"image"` + Quantity FlexInt `json:"variationQuantityPurchased"` +} + +// beijing is yeeke's server wall clock (fixed UTC+8, no DST). +var beijing = time.FixedZone("CST", 8*3600) + +// FlexInt accepts a JSON number or a numeric string; yeeke sends +// variationQuantityPurchased as a string (e.g. "2", see HAR). Empty, null or +// non-numeric values decode as 0 instead of failing the whole page. +type FlexInt int64 + +func (f *FlexInt) UnmarshalJSON(b []byte) error { + s := strings.Trim(strings.TrimSpace(string(b)), `"`) + if s == "" || s == "null" { + *f = 0 + return nil + } + n, err := strconv.ParseInt(s, 10, 64) + if err != nil { + *f = 0 + return nil + } + *f = FlexInt(n) + return nil } func (c *Client) List(ctx context.Context, pageNo, pageSize int) (ReturnPage, error) { diff --git a/server/app/goauto/yeekeclient/client_test.go b/server/app/goauto/yeekeclient/client_test.go index 5fbe066..98cdb76 100644 --- a/server/app/goauto/yeekeclient/client_test.go +++ b/server/app/goauto/yeekeclient/client_test.go @@ -8,6 +8,7 @@ import ( "net/http/httptest" "strings" "testing" + "time" ) func TestCaptchaLoginAndReadOnlyList(t *testing.T) { @@ -218,3 +219,42 @@ func TestAPIErrorMapsExpiredLoginToSessionInvalid(t *testing.T) { t.Fatal("code 401 must map to ErrSessionInvalid") } } + +// Field types taken from the HAR list response (values are fake): quantity is +// a numeric string, ids are strings, many fields are null, and times are +// naive Beijing wall-clock strings. +func TestListDecodesWebClientResponseShape(t *testing.T) { + const page = `{"success":true,"code":200,"message":"操作成功!","result":{ + "records":[{"id":"1000000000000000001","ordersn":"FAKE-ORDER-1","trackingNo":"FAKE-TRACK-1", + "shopId":"123","shopName":"示例店铺","wareCode":"W1","wareHouse":"A-01","wareName":"示例仓", + "status":"1","claimTime":"2026-09-21 14:13:29","createTime":"2026-09-21 14:13:29", + "updateTime":"2026-09-21 14:13:29","destroyDeadLine":"2026-10-21 14:13:29", + "airwayBill":null,"secondSaleOrdersn":null,"photoList":null, + "items":[{"id":null,"itemId":"48800000000","variationId":"435000000000","itemName":"示例商品", + "variationName":"黑色,L","image":"https://example.invalid/a.jpg","variationQuantityPurchased":"2", + "aneroidMarkup":false,"num":null}, + {"id":null,"itemId":"48800000000","variationId":"435000000001","itemName":"示例商品", + "variationName":"黑色,XL","image":"","variationQuantityPurchased":null}]}], + "total":1,"size":20,"current":1,"pages":1,"searchCount":true}}` + s := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(page)) + })) + defer s.Close() + c, _ := New(s.URL) + c.SetToken("opaque") + p, e := c.List(context.Background(), 1, 20) + if e != nil { + t.Fatalf("list: %v", e) + } + if len(p.Records) != 1 || len(p.Records[0].Items) != 2 || p.Total != 1 || p.Pages != 1 { + t.Fatalf("unexpected page: %+v", p) + } + if p.Records[0].Items[0].Quantity != 2 || p.Records[0].Items[1].Quantity != 0 { + t.Fatalf("quantities = %d, %d", p.Records[0].Items[0].Quantity, p.Records[0].Items[1].Quantity) + } + got := p.Records[0].CreateTime.Time.UTC().Format(time.RFC3339) + if got != "2026-09-21T06:13:29Z" { + t.Fatalf("createTime = %s, want Beijing 14:13:29 = 06:13:29Z", got) + } +}