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
+39 -32
View File
@@ -6,6 +6,7 @@ const (
FreightSourceShunyunbao = "SHUNYUNBAO"
FreightSyncOrderNumber = "ORDER_NUMBER"
FreightSyncCreatedRange = "CREATED_RANGE"
FreightCurrencyTWD = "TWD"
)
type FreightSyncStatus string
@@ -58,22 +59,24 @@ type FreightOrder struct {
}
type FreightOrderItem struct {
ID string
FreightOrderID string
ExternalItemID string
Title string
ProductSpec string
SKU string
Quantity *int
ProductThumbRef *string
PurchaseStatus *string
CanonicalSHA256 string
Revision int
IsPresent bool
FirstSyncRunID string
LastSyncRunID string
CreatedAt time.Time
UpdatedAt time.Time
ID string
FreightOrderID string
ExternalItemID string
Title string
ProductSpec string
SKU string
Quantity *int
ProductThumbRef *string
OriginalUnitPriceMinor *int64
OriginalCurrency string
PurchaseStatus *string
CanonicalSHA256 string
Revision int
IsPresent bool
FirstSyncRunID string
LastSyncRunID string
CreatedAt time.Time
UpdatedAt time.Time
}
type FreightOrderDetail struct {
@@ -114,13 +117,15 @@ type FreightSourceOrder struct {
}
type FreightSourceItem struct {
ExternalItemID string `json:"external_item_id"`
Title string `json:"title"`
ProductSpec string `json:"product_spec"`
SKU string `json:"sku"`
Quantity *int `json:"quantity"`
ProductThumbRef *string `json:"product_thumb_ref"`
PurchaseStatus *string `json:"purchase_status"`
ExternalItemID string `json:"external_item_id"`
Title string `json:"title"`
ProductSpec string `json:"product_spec"`
SKU string `json:"sku"`
Quantity *int `json:"quantity"`
ProductThumbRef *string `json:"product_thumb_ref"`
OriginalUnitPriceMinor *int64 `json:"original_unit_price_minor"`
OriginalCurrency string `json:"original_currency"`
PurchaseStatus *string `json:"purchase_status"`
}
type FreightImportBatch struct {
@@ -142,13 +147,15 @@ type FreightImportOrder struct {
}
type FreightImportItem struct {
ID string
ExternalItemID string
Title string
ProductSpec string
SKU string
Quantity *int
ProductThumbRef *string
PurchaseStatus *string
CanonicalSHA256 string
ID string
ExternalItemID string
Title string
ProductSpec string
SKU string
Quantity *int
ProductThumbRef *string
OriginalUnitPriceMinor *int64
OriginalCurrency string
PurchaseStatus *string
CanonicalSHA256 string
}
@@ -34,8 +34,11 @@ func TestClaimsMigrationPreservesHistoryAcrossUpDownUp(t *testing.T) {
if applied, err := runner.Up(ctx); err != nil {
t.Fatalf("initial Up() error = %v", err)
} else if applied != 14 {
t.Fatalf("initial Up() applied = %d, want 14", applied)
} else if applied != 15 {
t.Fatalf("initial Up() applied = %d, want 15", applied)
}
if err := runner.Down(ctx); err != nil {
t.Fatalf("initial Down(v15) error = %v", err)
}
if err := runner.Down(ctx); err != nil {
t.Fatalf("initial Down(v14) error = %v", err)
@@ -71,9 +74,14 @@ func TestClaimsMigrationPreservesHistoryAcrossUpDownUp(t *testing.T) {
seedClaimsHistoricalFixture(t, db)
if applied, err := runner.Up(ctx); err != nil {
t.Fatalf("Up(v5-v14) over historical data error = %v", err)
} else if applied != 10 {
t.Fatalf("Up(v5-v14) applied = %d, want 10", applied)
t.Fatalf("Up(v5-v15) over historical data error = %v", err)
} else if applied != 11 {
t.Fatalf("Up(v5-v15) applied = %d, want 11", applied)
}
assertClaimsHistory(t, db, true)
if err := runner.Down(ctx); err != nil {
t.Fatalf("Down(v15) with compatible history error = %v", err)
}
assertClaimsHistory(t, db, true)
@@ -133,9 +141,9 @@ func TestClaimsMigrationPreservesHistoryAcrossUpDownUp(t *testing.T) {
assertClaimsHistory(t, db, false)
if applied, err := runner.Up(ctx); err != nil {
t.Fatalf("final Up(v4-v14) error = %v", err)
} else if applied != 11 {
t.Fatalf("final Up(v4-v14) applied = %d, want 11", applied)
t.Fatalf("final Up(v4-v15) error = %v", err)
} else if applied != 12 {
t.Fatalf("final Up(v4-v15) applied = %d, want 12", applied)
}
assertClaimsHistory(t, db, true)
}
@@ -371,6 +379,9 @@ func TestClaimsMigrationDownFailsClosedForNewAuditData(t *testing.T) {
t.Fatalf("insert v4 audit event: %v", err)
}
if err := runner.Down(ctx); err != nil {
t.Fatalf("Down(v15) error = %v", err)
}
if err := runner.Down(ctx); err != nil {
t.Fatalf("Down(v14) error = %v", err)
}
@@ -27,8 +27,8 @@ func TestRunnerSupportsUpStatusDownAndIdempotentUp(t *testing.T) {
if err != nil {
t.Fatalf("Up() error = %v", err)
}
if applied != 14 {
t.Fatalf("Up() applied = %d, want 14", applied)
if applied != 15 {
t.Fatalf("Up() applied = %d, want 15", applied)
}
assertStatuses(t, runner, map[int64]bool{
1: true,
@@ -45,6 +45,7 @@ func TestRunnerSupportsUpStatusDownAndIdempotentUp(t *testing.T) {
12: true,
13: true,
14: true,
15: true,
})
applied, err = runner.Up(context.Background())
@@ -72,7 +73,8 @@ func TestRunnerSupportsUpStatusDownAndIdempotentUp(t *testing.T) {
11: true,
12: true,
13: true,
14: false,
14: true,
15: false,
})
applied, err = runner.Up(context.Background())
@@ -97,6 +99,7 @@ func TestRunnerSupportsUpStatusDownAndIdempotentUp(t *testing.T) {
12: true,
13: true,
14: true,
15: true,
})
}
@@ -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"
}
@@ -383,6 +383,9 @@ func TestAuthMigrationCanRollbackWithoutRebuildingPurchaseTasks(
if err != nil {
t.Fatalf("migration.New() error = %v", err)
}
if err := runner.Down(context.Background()); err != nil {
t.Fatalf("Down(v15) error = %v", err)
}
if err := runner.Down(context.Background()); err != nil {
t.Fatalf("Down(v14) error = %v", err)
}
@@ -432,9 +435,9 @@ func TestAuthMigrationCanRollbackWithoutRebuildingPurchaseTasks(
t.Fatal("purchase_tasks was lost during auth migration rollback")
}
if applied, err := runner.Up(context.Background()); err != nil {
t.Fatalf("Up(v3-v14) error = %v", err)
} else if applied != 12 {
t.Fatalf("Up(v3-v14) applied = %d, want 12", applied)
t.Fatalf("Up(v3-v15) error = %v", err)
} else if applied != 13 {
t.Fatalf("Up(v3-v15) applied = %d, want 13", applied)
}
}
@@ -318,10 +318,11 @@ func upsertFreightOrderItem(
ctx,
`INSERT INTO freight_order_items (
id, freight_order_id, external_item_id, title, product_spec,
sku, quantity, product_thumb_ref, purchase_status,
sku, quantity, product_thumb_ref, original_unit_price_minor,
original_currency, purchase_status,
canonical_sha256, revision, is_present, first_sync_run_id,
last_sync_run_id, created_at, updated_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 1, 1, ?, ?, ?, ?)
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 1, 1, ?, ?, ?, ?)
ON CONFLICT (freight_order_id, external_item_id)
DO UPDATE SET
title = excluded.title,
@@ -329,6 +330,8 @@ func upsertFreightOrderItem(
sku = excluded.sku,
quantity = excluded.quantity,
product_thumb_ref = excluded.product_thumb_ref,
original_unit_price_minor = excluded.original_unit_price_minor,
original_currency = excluded.original_currency,
purchase_status = excluded.purchase_status,
revision = CASE
WHEN freight_order_items.canonical_sha256 != excluded.canonical_sha256
@@ -347,6 +350,8 @@ func upsertFreightOrderItem(
item.SKU,
nullableFreightQuantity(item.Quantity),
nullableString(item.ProductThumbRef),
nullableInt64(item.OriginalUnitPriceMinor),
item.OriginalCurrency,
nullableString(item.PurchaseStatus),
item.CanonicalSHA256,
runID,
@@ -519,7 +524,8 @@ func (store *Store) GetFreightOrder(
ctx,
`SELECT
id, freight_order_id, external_item_id, title, product_spec,
sku, quantity, product_thumb_ref, purchase_status,
sku, quantity, product_thumb_ref, original_unit_price_minor,
original_currency, purchase_status,
canonical_sha256, revision, is_present, first_sync_run_id,
last_sync_run_id, created_at, updated_at
FROM freight_order_items
@@ -678,6 +684,7 @@ func scanFreightOrder(scanner rowScanner) (domain.FreightOrder, error) {
func scanFreightOrderItem(scanner rowScanner) (domain.FreightOrderItem, error) {
var item domain.FreightOrderItem
var quantity sql.NullInt64
var originalUnitPriceMinor sql.NullInt64
var thumb, purchaseStatus sql.NullString
var createdAt, updatedAt string
err := scanner.Scan(
@@ -689,6 +696,8 @@ func scanFreightOrderItem(scanner rowScanner) (domain.FreightOrderItem, error) {
&item.SKU,
&quantity,
&thumb,
&originalUnitPriceMinor,
&item.OriginalCurrency,
&purchaseStatus,
&item.CanonicalSHA256,
&item.Revision,
@@ -706,6 +715,9 @@ func scanFreightOrderItem(scanner rowScanner) (domain.FreightOrderItem, error) {
item.Quantity = &value
}
item.ProductThumbRef = optionalString(thumb)
if originalUnitPriceMinor.Valid {
item.OriginalUnitPriceMinor = &originalUnitPriceMinor.Int64
}
item.PurchaseStatus = optionalString(purchaseStatus)
item.CreatedAt, err = parseTimestamp(createdAt)
if err != nil {
@@ -57,6 +57,8 @@ func TestFreightImportIsAtomicIdempotentAndRevisioned(t *testing.T) {
t.Fatalf("StartFreightSync() error = %v", err)
}
batch := freightBatch(910, "a", "b")
price := int64(12950)
batch.Orders[0].Items[0].OriginalUnitPriceMinor = &price
if err := store.CompleteFreightSync(
ctx,
first,
@@ -74,10 +76,16 @@ func TestFreightImportIsAtomicIdempotentAndRevisioned(t *testing.T) {
if err != nil || len(detail.Items) != 2 {
t.Fatalf("detail = %+v, error = %v", detail, err)
}
if detail.Items[0].OriginalUnitPriceMinor == nil ||
*detail.Items[0].OriginalUnitPriceMinor != price ||
detail.Items[0].OriginalCurrency != domain.FreightCurrencyTWD {
t.Fatalf("item price metadata = %+v", detail.Items[0])
}
second := freightRun(904, userID, now.Add(time.Minute))
createAndStartFreightRun(t, store, second, "freight-key-2", "3")
unchanged := freightBatch(920, "a", "b")
unchanged.Orders[0].Items[0].OriginalUnitPriceMinor = &price
if err := store.CompleteFreightSync(
ctx,
second,
@@ -96,6 +104,7 @@ func TestFreightImportIsAtomicIdempotentAndRevisioned(t *testing.T) {
third := freightRun(905, userID, now.Add(2*time.Minute))
createAndStartFreightRun(t, store, third, "freight-key-3", "4")
changed := freightBatch(930, "a", "c")
changed.Orders[0].Items[0].OriginalUnitPriceMinor = &price
changed.Orders[0].CanonicalSHA256 = repeatHex("d")
changed.Orders[0].Items[1].Title = "变化后的商品"
changed.Orders[0].Items[1].CanonicalSHA256 = repeatHex("e")
@@ -113,10 +122,18 @@ func TestFreightImportIsAtomicIdempotentAndRevisioned(t *testing.T) {
detail.Items[1].Revision != 2 {
t.Fatalf("changed revisions = %+v", detail)
}
if _, err := db.Exec(
`UPDATE freight_order_items SET original_unit_price_minor = NULL`,
); err != nil {
t.Fatalf("clear metadata before rollback guard test: %v", err)
}
runner, err := migration.New(db)
if err != nil {
t.Fatalf("migration.New() error = %v", err)
}
if err := runner.Down(ctx); err != nil {
t.Fatalf("metadata migration down: %v", err)
}
if err := runner.Down(ctx); err != nil {
t.Fatalf("date sync migration down: %v", err)
}
@@ -251,6 +268,9 @@ func TestFreightDateSyncAdvancesWatermarkOnlyOnWholeBatchSuccess(
}
runner, _ := migration.New(db)
if err := runner.Down(ctx); err != nil {
t.Fatalf("metadata migration down: %v", err)
}
if err := runner.Down(ctx); err == nil {
t.Fatal("date sync migration down succeeded with retained watermark")
}
@@ -334,22 +354,24 @@ func freightBatch(index int, firstHash, secondHash string) domain.FreightImportB
CanonicalSHA256: repeatHex("c"),
Items: []domain.FreightImportItem{
{
ID: uuid(index + 1),
ExternalItemID: "88",
Title: "商品一",
ProductSpec: "黑色,L",
SKU: "BLACK-L",
Quantity: &quantityOne,
CanonicalSHA256: repeatHex(firstHash),
ID: uuid(index + 1),
ExternalItemID: "88",
Title: "商品一",
ProductSpec: "黑色,L",
SKU: "黑色,L",
Quantity: &quantityOne,
OriginalCurrency: domain.FreightCurrencyTWD,
CanonicalSHA256: repeatHex(firstHash),
},
{
ID: uuid(index + 2),
ExternalItemID: "89",
Title: "商品二",
ProductSpec: "白色,M",
SKU: "WHITE-M",
Quantity: &quantityTwo,
CanonicalSHA256: repeatHex(secondHash),
ID: uuid(index + 2),
ExternalItemID: "89",
Title: "商品二",
ProductSpec: "白色,M",
SKU: "白色,M",
Quantity: &quantityTwo,
OriginalCurrency: domain.FreightCurrencyTWD,
CanonicalSHA256: repeatHex(secondHash),
},
},
}}}
@@ -21,7 +21,8 @@ func (store *Store) GetProcurementSourceItem(
`SELECT
item.id, item.freight_order_id, item.external_item_id,
item.title, item.product_spec, item.sku, item.quantity,
item.product_thumb_ref, item.purchase_status,
item.product_thumb_ref, item.original_unit_price_minor,
item.original_currency, item.purchase_status,
item.canonical_sha256, item.revision, item.is_present,
item.first_sync_run_id, item.last_sync_run_id,
item.created_at, item.updated_at
@@ -238,6 +238,9 @@ func TestProcurementRequestsArePerItemAndTaskSnapshotIsImmutable(
if err != nil {
t.Fatalf("migration.New() error = %v", err)
}
if err := runner.Down(ctx); err != nil {
t.Fatalf("metadata migration down: %v", err)
}
if err := runner.Down(ctx); err != nil {
t.Fatalf("date sync migration down: %v", err)
}
@@ -472,8 +472,10 @@ func TestAdminFreightAPIImportsAllItemsWithoutPII(t *testing.T) {
"",
)
if detail.Code != http.StatusOK ||
!strings.Contains(detail.Body.String(), `"sku":"BLACK-L"`) ||
!strings.Contains(detail.Body.String(), `"sku":"WHITE-M"`) ||
!strings.Contains(detail.Body.String(), `"sku":"黑色,L"`) ||
!strings.Contains(detail.Body.String(), `"sku":"白色,M"`) ||
!strings.Contains(detail.Body.String(), `"original_unit_price_minor":12950`) ||
!strings.Contains(detail.Body.String(), `"original_currency":"TWD"`) ||
detail.Header().Get("Cache-Control") != "no-store" {
t.Fatalf("freight detail status/body = %d / %s", detail.Code, detail.Body)
}
@@ -718,7 +720,7 @@ func TestAdminProcurementAPIProducesImmutablePendingTask(t *testing.T) {
)
for _, required := range []string{
`"title":"商品一"`,
`"sku":"BLACK-L"`,
`"sku":"黑色,L"`,
`"quantity":1`,
`"image_asset_id":"` + assetBody.ID + `"`,
`"description":"ERP 规格:黑色,L"`,
@@ -873,6 +875,9 @@ func TestAdminOrderAuthorizationIsIdempotentAndRevisioned(t *testing.T) {
if err != nil {
t.Fatalf("migration.New() error = %v", err)
}
if err := runner.Down(context.Background()); err != nil {
t.Fatalf("metadata migration down: %v", err)
}
if err := runner.Down(context.Background()); err != nil {
t.Fatalf("date sync migration down: %v", err)
}
@@ -1263,6 +1268,8 @@ func (staticFreightSource) QueryOrder(
created := "2026-07-28 08:00"
quantityOne := 1
quantityTwo := 2
priceOne := int64(12950)
priceTwo := int64(8800)
return domain.FreightSourceBatch{
SchemaVersion: 1,
Query: domain.FreightSourceQuery{
@@ -1275,18 +1282,22 @@ func (staticFreightSource) QueryOrder(
SourceCreatedAt: &created,
Items: []domain.FreightSourceItem{
{
ExternalItemID: "88",
Title: "商品一",
ProductSpec: "黑色,L",
SKU: "BLACK-L",
Quantity: &quantityOne,
ExternalItemID: "88",
Title: "商品一",
ProductSpec: "黑色,L",
SKU: "黑色,L",
Quantity: &quantityOne,
OriginalUnitPriceMinor: &priceOne,
OriginalCurrency: domain.FreightCurrencyTWD,
},
{
ExternalItemID: "89",
Title: "商品二",
ProductSpec: "白色,M",
SKU: "WHITE-M",
Quantity: &quantityTwo,
ExternalItemID: "89",
Title: "商品二",
ProductSpec: "白色,M",
SKU: "白色,M",
Quantity: &quantityTwo,
OriginalUnitPriceMinor: &priceTwo,
OriginalCurrency: domain.FreightCurrencyTWD,
},
},
}},
@@ -713,6 +713,9 @@ func TestDeviceExecutionResultsAreIdempotentAndAuditable(t *testing.T) {
if err != nil {
t.Fatalf("migration.New() after review error = %v", err)
}
if err := runner.Down(context.Background()); err != nil {
t.Fatalf("metadata migration down: %v", err)
}
if err := runner.Down(context.Background()); err != nil {
t.Fatalf("date sync migration down: %v", err)
}
@@ -736,8 +739,8 @@ func TestDeviceExecutionResultsAreIdempotentAndAuditable(t *testing.T) {
}
if applied, err := runner.Up(context.Background()); err != nil {
t.Fatalf("restore device command migration: %v", err)
} else if applied != 6 {
t.Fatalf("restored migrations = %d, want 6", applied)
} else if applied != 7 {
t.Fatalf("restored migrations = %d, want 7", applied)
}
completePayload := fmt.Sprintf(
@@ -1426,6 +1429,9 @@ func TestDeviceOrderCommandDeliveryAndAcknowledgementAreRecoverable(
if err != nil {
t.Fatalf("migration.New() error = %v", err)
}
if err := runner.Down(context.Background()); err != nil {
t.Fatalf("metadata migration down: %v", err)
}
if err := runner.Down(context.Background()); err != nil {
t.Fatalf("date sync migration down: %v", err)
}
@@ -206,17 +206,19 @@ func (h *adminHandlers) freightOrderDetail(ctx *gin.Context) {
items := make([]gin.H, 0, len(detail.Items))
for _, item := range detail.Items {
items = append(items, gin.H{
"id": item.ID,
"external_item_id": item.ExternalItemID,
"title": item.Title,
"product_spec": item.ProductSpec,
"sku": item.SKU,
"quantity": item.Quantity,
"product_thumb_ref": item.ProductThumbRef,
"purchase_status": item.PurchaseStatus,
"revision": item.Revision,
"canonical_sha256": item.CanonicalSHA256,
"updated_at": formatTime(item.UpdatedAt),
"id": item.ID,
"external_item_id": item.ExternalItemID,
"title": item.Title,
"product_spec": item.ProductSpec,
"sku": item.SKU,
"quantity": item.Quantity,
"product_thumb_ref": item.ProductThumbRef,
"original_unit_price_minor": item.OriginalUnitPriceMinor,
"original_currency": item.OriginalCurrency,
"purchase_status": item.PurchaseStatus,
"revision": item.Revision,
"canonical_sha256": item.CanonicalSHA256,
"updated_at": formatTime(item.UpdatedAt),
})
}
ctx.Header("Cache-Control", "no-store")
+11 -9
View File
@@ -150,15 +150,17 @@ type FreightOrder struct {
}
type FreightOrderItem struct {
ID string
ExternalItemID string
Title string
ProductSpec string
SKU string
Quantity int
ProductThumbRef string
PurchaseStatus string
Revision int
ID string
ExternalItemID string
Title string
ProductSpec string
SKU string
Quantity int
ProductThumbRef string
OriginalUnitPriceMinor *int64
OriginalCurrency string
PurchaseStatus string
Revision int
}
type FreightOrderDetail struct {
@@ -160,14 +160,16 @@ func (adapter *UsecaseAdapter) GetFreightOrder(
items := make([]FreightItemReview, 0, len(detail.Items))
for _, item := range detail.Items {
view := FreightOrderItem{
ID: item.ID,
ExternalItemID: item.ExternalItemID,
Title: item.Title,
ProductSpec: item.ProductSpec,
SKU: item.SKU,
ProductThumbRef: stringValue(item.ProductThumbRef),
PurchaseStatus: stringValue(item.PurchaseStatus),
Revision: item.Revision,
ID: item.ID,
ExternalItemID: item.ExternalItemID,
Title: item.Title,
ProductSpec: item.ProductSpec,
SKU: item.SKU,
ProductThumbRef: stringValue(item.ProductThumbRef),
OriginalUnitPriceMinor: item.OriginalUnitPriceMinor,
OriginalCurrency: item.OriginalCurrency,
PurchaseStatus: stringValue(item.PurchaseStatus),
Revision: item.Revision,
}
if item.Quantity != nil {
view.Quantity = *item.Quantity
+25 -16
View File
@@ -728,6 +728,9 @@ func (service *FreightService) normalize(
!validBytes(sourceItem.ProductSpec, 1024) ||
!validBytes(sourceItem.SKU, 512) ||
!validOptional(sourceItem.ProductThumbRef, 512) ||
sourceItem.OriginalCurrency != domain.FreightCurrencyTWD ||
(sourceItem.OriginalUnitPriceMinor != nil &&
*sourceItem.OriginalUnitPriceMinor < 0) ||
!validOptional(sourceItem.PurchaseStatus, 128) ||
(sourceItem.Quantity != nil && *sourceItem.Quantity <= 0) {
return domain.FreightImportBatch{}, errors.New("invalid freight item fields")
@@ -737,26 +740,32 @@ func (service *FreightService) normalize(
return domain.FreightImportBatch{}, err
}
item := domain.FreightImportItem{
ID: itemID,
ExternalItemID: itemExternalID,
Title: sourceItem.Title,
ProductSpec: sourceItem.ProductSpec,
SKU: sourceItem.SKU,
Quantity: sourceItem.Quantity,
ProductThumbRef: cleanOptional(sourceItem.ProductThumbRef),
PurchaseStatus: cleanOptional(sourceItem.PurchaseStatus),
ID: itemID,
ExternalItemID: itemExternalID,
Title: sourceItem.Title,
ProductSpec: sourceItem.ProductSpec,
SKU: sourceItem.SKU,
Quantity: sourceItem.Quantity,
ProductThumbRef: cleanOptional(sourceItem.ProductThumbRef),
OriginalUnitPriceMinor: sourceItem.OriginalUnitPriceMinor,
OriginalCurrency: sourceItem.OriginalCurrency,
PurchaseStatus: cleanOptional(sourceItem.PurchaseStatus),
}
item.CanonicalSHA256 = hashJSON(struct {
ExternalItemID string `json:"external_item_id"`
Title string `json:"title"`
ProductSpec string `json:"product_spec"`
SKU string `json:"sku"`
Quantity *int `json:"quantity"`
ProductThumbRef *string `json:"product_thumb_ref"`
PurchaseStatus *string `json:"purchase_status"`
ExternalItemID string `json:"external_item_id"`
Title string `json:"title"`
ProductSpec string `json:"product_spec"`
SKU string `json:"sku"`
Quantity *int `json:"quantity"`
ProductThumbRef *string `json:"product_thumb_ref"`
OriginalUnitPriceMinor *int64 `json:"original_unit_price_minor"`
OriginalCurrency string `json:"original_currency"`
PurchaseStatus *string `json:"purchase_status"`
}{
item.ExternalItemID, item.Title, item.ProductSpec, item.SKU,
item.Quantity, item.ProductThumbRef, item.PurchaseStatus,
item.Quantity, item.ProductThumbRef,
item.OriginalUnitPriceMinor, item.OriginalCurrency,
item.PurchaseStatus,
})
order.Items = append(order.Items, item)
}
@@ -33,6 +33,19 @@ func TestFreightNormalizationHashExcludesInternalIDs(t *testing.T) {
second.Orders[0].CanonicalSHA256,
)
}
changed := validFreightSource()
changedPrice := *changed.Orders[0].Items[0].OriginalUnitPriceMinor + 1
changed.Orders[0].Items[0].OriginalUnitPriceMinor = &changedPrice
changedBatch, err := firstService.normalize(changed)
if err != nil {
t.Fatalf("changed price normalize error = %v", err)
}
if first.Orders[0].CanonicalSHA256 ==
changedBatch.Orders[0].CanonicalSHA256 ||
first.Orders[0].Items[0].CanonicalSHA256 ==
changedBatch.Orders[0].Items[0].CanonicalSHA256 {
t.Fatal("canonical hash ignored original unit price")
}
}
func TestFreightNormalizationRejectsConflictingIdentityAndInvalidTime(
@@ -466,6 +479,7 @@ func validFreightSource() domain.FreightSourceBatch {
thumb := "190"
itemPurchaseStatus := "0"
quantity := 2
originalUnitPriceMinor := int64(12950)
canceled := false
return domain.FreightSourceBatch{
SchemaVersion: 1,
@@ -481,13 +495,15 @@ func validFreightSource() domain.FreightSourceBatch {
PurchaseStatus: &purchaseStatus,
IsCanceled: &canceled,
Items: []domain.FreightSourceItem{{
ExternalItemID: "88",
Title: "商品",
ProductSpec: "黑色,L",
SKU: "BLACK-L",
Quantity: &quantity,
ProductThumbRef: &thumb,
PurchaseStatus: &itemPurchaseStatus,
ExternalItemID: "88",
Title: "商品",
ProductSpec: "黑色,L",
SKU: "黑色,L",
Quantity: &quantity,
ProductThumbRef: &thumb,
OriginalUnitPriceMinor: &originalUnitPriceMinor,
OriginalCurrency: domain.FreightCurrencyTWD,
PurchaseStatus: &itemPurchaseStatus,
}},
}},
}
@@ -0,0 +1,29 @@
-- +goose Up
ALTER TABLE freight_order_items
ADD COLUMN original_unit_price_minor INTEGER
CHECK (original_unit_price_minor IS NULL OR original_unit_price_minor >= 0);
ALTER TABLE freight_order_items
ADD COLUMN original_currency TEXT NOT NULL DEFAULT 'TWD'
CHECK (original_currency = 'TWD');
-- +goose Down
CREATE TEMP TABLE freight_item_metadata_v15_down_guard (
allowed INTEGER NOT NULL CHECK (allowed = 1)
);
INSERT INTO freight_item_metadata_v15_down_guard (allowed)
SELECT CASE
WHEN EXISTS (
SELECT 1
FROM freight_order_items
WHERE original_unit_price_minor IS NOT NULL
)
THEN 0
ELSE 1
END;
DROP TABLE freight_item_metadata_v15_down_guard;
ALTER TABLE freight_order_items DROP COLUMN original_currency;
ALTER TABLE freight_order_items DROP COLUMN original_unit_price_minor;
+8 -1
View File
@@ -186,9 +186,11 @@ T-203 成功返回 `201`。使用相同 `Idempotency-Key` 和相同图片内容
"external_item_id": "880011",
"title": "商品标题",
"product_spec": "灰色,2XL",
"sku": "原始 SKU",
"sku": "灰色,2XL",
"quantity": 2,
"product_thumb_ref": "190000000",
"original_unit_price_minor": 12950,
"original_currency": "TWD",
"purchase_status": "0"
}]
}]
@@ -198,6 +200,11 @@ T-203 成功返回 `201`。使用相同 `Idempotency-Key` 和相同图片内容
不得返回 receiver、receiverTel、receiverAddr、Cookie、JWT、ERP 用户资料或完整原始
对象。多商品必须全部保留;缺失详情返回协议错误,不允许部分成功。
商品 `product_spec` 和采购使用的 `sku` 均来自 ERP `productSpec`,不得静默回退
`sku/variationSku`。`productPrice` 严格转换为 `original_unit_price_minor`;缺失价格为
`null`,币种固定为 `TWD`。`product_thumb_ref` 来自商品 `productThumb`,存在时必须是
规范化正整数,不能使用货运单 ID、商品明细 ID 或完整 URL。
### Go ERP 会话(T-230、T-236)
`/erp`、`/erp/captcha`、`/erp/login` 和 `/api/v1/erp-session*` 不再暴露。创建货运同步
+7 -4
View File
@@ -198,6 +198,10 @@
| `docs/tasks/T-233.md` | DONE | 修复预检错误映射并增加受控 ERP 诊断日志 |
| `docs/tasks/T-234.md` | DONE | 在受控诊断模式输出 OCR 验证码文本 |
| `docs/tasks/T-235.md` | DONE | 修复顺运宝登录后用户会话校验协议 |
| `docs/tasks/T-236.md` | DONE | 验证码错误时重新取图和 OCR,最多额外重试 6 次 |
| `docs/tasks/T-237.md` | DONE | 完整单号在 55 秒预算内同步导入并可靠收敛终态 |
| `docs/tasks/T-238.md` | DONE | 兼容顺运宝详情分钟精度时间 |
| `docs/tasks/T-239.md` | DONE | 冻结货运规格 SKU、TWD 原始单价和图片引用契约 |
| `docs/design/` | 已确认 | T-202 原型索引、4 个管理页和 7 个 Android 页面 |
| `deepseek总结.txt` | 已有 | 历史讨论摘要,不是正式需求权威 |
| `android-buyer/` | 已有 | Roubao `main` 固定 commit 的 Android 基线 |
@@ -209,12 +213,11 @@
## 任务摘要
- 已完成:T-001 至 T-004、T-101 至 T-104、T-201 至 T-219。
- 已完成:另含 T-220 至 T-235 ERP 契约、货运存储、采购需求生成、日期增量同步、Go
- 已完成:另含 T-220 至 T-239 ERP 契约、货运存储、采购需求生成、日期增量同步、Go
直连协议、OCR 会话预检、稳定预检错误、安全诊断日志、直连 `FreightSource`、旧 Connector
清理和受控本地凭证加载。
清理、受控本地凭证加载、同步单号导入、分钟时间兼容和货运商品元数据契约。
- 进行中:无。
- 下一步:使用 `start-backend.bat --erp-debug` 重启 API 并重新执行受控单号 smoke;确认用户
校验成功后继续观察 listTotal/list/detail 请求和同步结果。
- 下一步:实现商品 `productThumb` 图片的后端受控下载、本地保存和鉴权访问。
## 当前可运行内容
+12 -8
View File
@@ -4,7 +4,7 @@ title: 冻结货运商品规格、原始价格与图片引用契约
phase: 2
deps:
- T-238
status: TODO
status: DONE
created: 2026-07-29
context_ref: 01be7e7
work_branch: null
@@ -55,13 +55,13 @@ ERP `sku/variationSku`,与本项目需要按颜色、尺码硬匹配的 `produ
## 验收要点
- [ ] `productSpec` 同时成为货运商品的规格和采购 SKU,缺失时不使用其他 ERP SKU 猜测。
- [ ] `productPrice` 的合法整数、小数和数字字符串精确转换为 TWD 最小单位。
- [ ] 缺失价格为 `NULL`;负数、指数、超过两位小数和越界值拒绝为 ERP 协议错误。
- [ ] `productThumb` 仅接受可规范化的正整数引用,且不与货运单 ID 混用。
- [ ] 金额、币种和新 SKU 进入 canonical hash,并通过 SQLite 往返和 revision 测试。
- [ ] API/Web 适配层能读取新增字段;既有采购快照和任务不可变语义不回归。
- [ ] 标准 Go 测试、race、vet 和三个入口构建通过。
- [x] `productSpec` 同时成为货运商品的规格和采购 SKU,缺失时不使用其他 ERP SKU 猜测。
- [x] `productPrice` 的合法整数、小数和数字字符串精确转换为 TWD 最小单位。
- [x] 缺失价格为 `NULL`;负数、指数、超过两位小数和越界值拒绝为 ERP 协议错误。
- [x] `productThumb` 仅接受可规范化的正整数引用,且不与货运单 ID 混用。
- [x] 金额、币种和新 SKU 进入 canonical hash,并通过 SQLite 往返和 revision 测试。
- [x] API/Web 适配层能读取新增字段;既有采购快照和任务不可变语义不回归。
- [x] 标准 Go 测试、race、vet 和三个入口构建通过。
## 边界
@@ -73,3 +73,7 @@ ERP `sku/variationSku`,与本项目需要按颜色、尺码硬匹配的 `produ
- 2026-07-29:创建任务。确认图片 URL 的查询参数必须来自商品 `productThumb`,不能使用
货运单或商品明细 ID;金额使用定点最小单位,图片网络获取拆分到后续任务。
- 2026-07-29:增加 migration 15 和货运领域/API/Web 字段;normalizer 以 `productSpec`
同时生成规格和采购 SKU,严格解析 TWD 原始单价及正整数图片引用。金额进入商品与货运单
canonical hash,SQLite 往返、revision、采购不可变快照和脱敏 API 回归通过。标准 Go
测试、race、vet 及三个入口构建均通过;真实 ERP JSON 保持未跟踪。