feat(t239): persist freight item metadata

This commit is contained in:
QiuSW
2026-07-29 14:54:58 +08:00
parent 42006acedb
commit 3bd9726ddc
23 changed files with 476 additions and 158 deletions
@@ -156,21 +156,109 @@ func normalizeItem(value map[string]any) (domain.FreightSourceItem, error) {
if title == "" {
title = text(value["detailProductName"])
}
sku := text(value["sku"])
if sku == "" {
sku = text(value["variationSku"])
productSpec := text(value["productSpec"])
productThumbRef, err := optionalExternalID(value["productThumb"])
if err != nil {
return domain.FreightSourceItem{}, err
}
originalUnitPriceMinor, err := priceMinor(value["productPrice"])
if err != nil {
return domain.FreightSourceItem{}, err
}
return domain.FreightSourceItem{
ExternalItemID: externalItemID,
Title: title,
ProductSpec: text(value["productSpec"]),
SKU: sku,
Quantity: positiveInt(value["productQty"]),
ProductThumbRef: optionalScalar(value["productThumb"]),
PurchaseStatus: optionalScalar(value["purchaseStatus"]),
ExternalItemID: externalItemID,
Title: title,
ProductSpec: productSpec,
SKU: productSpec,
Quantity: positiveInt(value["productQty"]),
ProductThumbRef: productThumbRef,
OriginalUnitPriceMinor: originalUnitPriceMinor,
OriginalCurrency: domain.FreightCurrencyTWD,
PurchaseStatus: optionalScalar(value["purchaseStatus"]),
}, nil
}
func optionalExternalID(value any) (*string, error) {
if value == nil {
return nil, nil
}
if typed, ok := value.(string); ok && strings.TrimSpace(typed) == "" {
return nil, nil
}
normalized, err := externalID(value)
if err != nil {
return nil, err
}
return &normalized, nil
}
func priceMinor(value any) (*int64, error) {
if value == nil {
return nil, nil
}
var raw string
switch typed := value.(type) {
case string:
raw = strings.TrimSpace(typed)
case json.Number:
raw = string(typed)
case float64:
if math.IsNaN(typed) || math.IsInf(typed, 0) || typed < 0 {
return nil, errInvalidProtocolInput
}
raw = strconv.FormatFloat(typed, 'f', -1, 64)
case int:
raw = strconv.Itoa(typed)
case int64:
raw = strconv.FormatInt(typed, 10)
case uint64:
raw = strconv.FormatUint(typed, 10)
default:
return nil, errInvalidProtocolInput
}
if raw == "" {
return nil, nil
}
whole, fraction, hasFraction := strings.Cut(raw, ".")
if whole == "" || strings.Contains(fraction, ".") ||
!decimalDigits(whole) || (hasFraction && !decimalDigits(fraction)) ||
len(fraction) > 2 {
return nil, errInvalidProtocolInput
}
wholeValue, err := strconv.ParseUint(whole, 10, 64)
if err != nil || wholeValue > uint64(math.MaxInt64)/100 {
return nil, errInvalidProtocolInput
}
minor := wholeValue * 100
if hasFraction {
fractionValue, err := strconv.ParseUint(fraction, 10, 8)
if err != nil {
return nil, errInvalidProtocolInput
}
if len(fraction) == 1 {
fractionValue *= 10
}
minor += fractionValue
}
if minor > uint64(math.MaxInt64) {
return nil, errInvalidProtocolInput
}
result := int64(minor)
return &result, nil
}
func decimalDigits(value string) bool {
if value == "" {
return false
}
for _, char := range value {
if char < '0' || char > '9' {
return false
}
}
return true
}
func externalID(value any) (string, error) {
var normalized uint64
switch typed := value.(type) {
@@ -86,8 +86,11 @@ func TestNormalizeFreightFixtureUsesOnlyAllowlist(t *testing.T) {
order := result.Orders[0]
if order.ExternalStockID != "12" || order.SourceCode != "SANITIZED-CODE-12" ||
order.ShopName == nil || *order.ShopName != "Sanitized shop" ||
order.Items[0].ExternalItemID != "88" || order.Items[0].SKU != "BLACK-L" ||
order.Items[0].Quantity == nil || *order.Items[0].Quantity != 2 {
order.Items[0].ExternalItemID != "88" || order.Items[0].SKU != "Black,L" ||
order.Items[0].Quantity == nil || *order.Items[0].Quantity != 2 ||
order.Items[0].OriginalUnitPriceMinor == nil ||
*order.Items[0].OriginalUnitPriceMinor != 12950 ||
order.Items[0].OriginalCurrency != domain.FreightCurrencyTWD {
t.Fatalf("allowlist result = %#v", order)
}
encoded, err := json.Marshal(result)
@@ -105,6 +108,77 @@ func TestNormalizeFreightFixtureUsesOnlyAllowlist(t *testing.T) {
}
}
func TestNormalizeFreightPriceAndThumbContract(t *testing.T) {
tests := []struct {
name string
price any
thumb any
wantMinor *int64
wantThumb *string
wantErr bool
}{
{name: "integer", price: json.Number("12"), thumb: json.Number("190"), wantMinor: int64Pointer(1200), wantThumb: stringPointer("190")},
{name: "one decimal", price: "12.3", thumb: "0190", wantMinor: int64Pointer(1230), wantThumb: stringPointer("190")},
{name: "two decimals", price: json.Number("12.34"), wantMinor: int64Pointer(1234)},
{name: "missing", price: "", thumb: "", wantMinor: nil, wantThumb: nil},
{name: "negative", price: "-1", wantErr: true},
{name: "exponent", price: json.Number("1e2"), wantErr: true},
{name: "excess precision", price: "1.001", wantErr: true},
{name: "overflow", price: "92233720368547758.08", wantErr: true},
{name: "invalid thumb text", price: "1", thumb: "https://invalid.example/image", wantErr: true},
{name: "invalid thumb zero", price: "1", thumb: 0, wantErr: true},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
result, err := NormalizeFreightResult(
RawQuery{Mode: domain.FreightSyncOrderNumber},
[]RawRecord{{
Stock: map[string]any{"id": 12, "code": "SANITIZED"},
Detail: map[string]any{
"id": 12,
"details": []any{map[string]any{
"id": 88,
"productSpec": "Black,L",
"productPrice": test.price,
"productThumb": test.thumb,
}},
},
}},
)
if test.wantErr {
if !errors.Is(err, domain.ErrFreightSourceProtocol) {
t.Fatalf("NormalizeFreightResult() error = %v", err)
}
return
}
if err != nil {
t.Fatalf("NormalizeFreightResult() error = %v", err)
}
item := result.Orders[0].Items[0]
if !equalInt64Pointer(item.OriginalUnitPriceMinor, test.wantMinor) ||
!equalStringPointer(item.ProductThumbRef, test.wantThumb) ||
item.ProductSpec != "Black,L" || item.SKU != "Black,L" ||
item.OriginalCurrency != domain.FreightCurrencyTWD {
t.Fatalf("normalized item = %#v", item)
}
})
}
}
func int64Pointer(value int64) *int64 {
return &value
}
func equalInt64Pointer(left, right *int64) bool {
return left == nil && right == nil ||
left != nil && right != nil && *left == *right
}
func equalStringPointer(left, right *string) bool {
return left == nil && right == nil ||
left != nil && right != nil && *left == *right
}
func TestNormalizeFreightNeverLeaksRawValuesInErrors(t *testing.T) {
_, err := NormalizeFreightResult(
RawQuery{Mode: domain.FreightSyncOrderNumber},
@@ -51,7 +51,7 @@ func TestSessionManagerQueryOrderUsesVerifiedSessionAndAllowlist(t *testing.T) {
t.Fatalf("detail query = %q", request.URL.RawQuery)
}
assertDetailPayload(t, request, []uint64{12})
_, _ = writer.Write([]byte(`{"status":true,"data":{"list":[{"id":12,"shopName":"测试店铺","created":"2026-07-28 08:00:00","details":[{"id":88,"productTitle":"商品一","productSpec":"黑色,L","sku":"BLACK-L","productQty":2,"receiverTel":"private-item-phone"},{"id":89,"productTitle":"商品二","productSpec":"黑色,XL","sku":"BLACK-XL","productQty":1}]}]}}`))
_, _ = writer.Write([]byte(`{"status":true,"data":{"list":[{"id":12,"shopName":"测试店铺","created":"2026-07-28 08:00:00","details":[{"id":88,"productTitle":"商品一","productSpec":"黑色,L","sku":"IGNORED-1","productQty":2,"productPrice":129.5,"productThumb":190,"receiverTel":"private-item-phone"},{"id":89,"productTitle":"商品二","productSpec":"黑色,XL","variationSku":"IGNORED-2","productQty":1,"productPrice":"88","productThumb":"191"}]}]}}`))
default:
writer.WriteHeader(http.StatusNotFound)
}
@@ -67,8 +67,10 @@ func TestSessionManagerQueryOrderUsesVerifiedSessionAndAllowlist(t *testing.T) {
if result.SchemaVersion != 1 || result.Query.Mode != domain.FreightSyncOrderNumber ||
len(result.Orders) != 1 || result.Orders[0].ExternalStockID != "12" ||
result.Orders[0].ShopName == nil || *result.Orders[0].ShopName != "测试店铺" ||
len(result.Orders[0].Items) != 2 || result.Orders[0].Items[0].SKU != "BLACK-L" ||
result.Orders[0].Items[1].SKU != "BLACK-XL" {
len(result.Orders[0].Items) != 2 || result.Orders[0].Items[0].SKU != "黑色,L" ||
result.Orders[0].Items[1].SKU != "黑色,XL" ||
result.Orders[0].Items[0].OriginalUnitPriceMinor == nil ||
*result.Orders[0].Items[0].OriginalUnitPriceMinor != 12950 {
t.Fatalf("QueryOrder() = %#v", result)
}
encoded, err := json.Marshal(result)
@@ -32,6 +32,7 @@
"sku": "BLACK-L",
"productQty": 2,
"productThumb": 190,
"productPrice": "129.50",
"purchaseStatus": 0,
"receiverTel": "item-phone-not-allowed"
}