From 15ccab39606e75a65e2af07cb4a029be62fb43c5 Mon Sep 17 00:00:00 2001 From: QiuSW <105186638@qq.com> Date: Thu, 8 Oct 2026 16:54:48 +0800 Subject: [PATCH] fix(task): auto-link optional-metadata partial image searches (#368) --- server/app/goauto/task/image_search_link.go | 32 +- .../goauto/task/image_search_partial_test.go | 324 ++++++++++++++++++ 2 files changed, 353 insertions(+), 3 deletions(-) create mode 100644 server/app/goauto/task/image_search_partial_test.go diff --git a/server/app/goauto/task/image_search_link.go b/server/app/goauto/task/image_search_link.go index 2faa45d..8ea9f35 100644 --- a/server/app/goauto/task/image_search_link.go +++ b/server/app/goauto/task/image_search_link.go @@ -2,6 +2,7 @@ package task import ( "context" + "encoding/json" "errors" "fmt" "log" @@ -15,8 +16,8 @@ import ( ) // autoLinkImageSearch is deliberately a narrow post-result hook. It only -// writes an association when the task was a complete collection, the frozen -// original association is unchanged, and the target product is active. Manual +// writes an association when the collection is complete or missing only optional +// metadata, the frozen original association is unchanged, and the target product is active. Manual // changes made while the Agent was running win by the CAS predicate. // autoLinkedImageSearch 汇报本次是否确实写入了关联,以及关联覆盖了哪些 SYB 明细。 // 只有确实写入时才值得触发后续的规格匹配(#287)。 @@ -32,7 +33,7 @@ func (service *Service) autoLinkImageSearch(ctx context.Context, taskID uint64) if err := service.DB.WithContext(ctx).First(&task, taskID).Error; err != nil { return autoLinkedImageSearch{}, internalError(err) } - if task.Source != models.CollectionTaskSourceImageSearch || task.Status != models.TaskStatusCompleted || task.PDDProductID == nil || task.ImageSearchSnapshot == nil { + if task.Source != models.CollectionTaskSourceImageSearch || !imageSearchResultAllowsAutoLink(task) || task.PDDProductID == nil || task.ImageSearchSnapshot == nil { return autoLinkedImageSearch{}, nil } snapshot, err := loadImageSearchSnapshot(task) @@ -94,6 +95,31 @@ func (service *Service) autoLinkImageSearch(ctx context.Context, taskID uint64) }, nil } +// #368: Keep completed behavior unchanged. A partial result is eligible only +// with explicit, nonempty evidence that every missing field is optional. +// Unknown/malformed missing data must not silently widen this whitelist. +func imageSearchResultAllowsAutoLink(task models.CollectionTask) bool { + if task.Status == models.TaskStatusCompleted { + return true + } + if task.Status != models.TaskStatusCompletedPartial || task.MissingJSON == nil { + return false + } + var missing []string + if err := json.Unmarshal([]byte(*task.MissingJSON), &missing); err != nil || len(missing) == 0 { + return false + } + for _, field := range missing { + switch field { + case "reviewCount", "salesText", "shopName": + // Optional descriptive metadata; never colors, sizes or prices. + default: + return false + } + } + return true +} + // matchSpecsAfterImageSearch triggers the existing SYB batch spec match for the // details the image-search task just linked (#287). // diff --git a/server/app/goauto/task/image_search_partial_test.go b/server/app/goauto/task/image_search_partial_test.go new file mode 100644 index 0000000..bb2ee35 --- /dev/null +++ b/server/app/goauto/task/image_search_partial_test.go @@ -0,0 +1,324 @@ +package task + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "reflect" + "sync/atomic" + "testing" + + "go-admin/app/goauto/models" + "go-admin/app/goauto/shopeeproduct" + + "github.com/google/uuid" + "gorm.io/gorm" +) + +// This fixture leaves the hook uncalled so tests can change the submitted +// status, missing fields and association independently of the assertions. +func seedPartialImageSearch(t *testing.T, db *gorm.DB, deviceID *uint64) (models.CollectionTask, models.ShopeeProduct) { + t.Helper() + record := createTask(t, db, deviceID) + if err := db.Model(&models.PDDProduct{}).Where("id = ?", *record.PDDProductID).Update("status", "active").Error; err != nil { + t.Fatal(err) + } + shopee := models.ShopeeProduct{ + ShopeeItemID: "test-item", Title: "test", Currency: "TWD", + SpecsJSON: `[{"name":"颜色","role":"color","values":[{"name":"黑色","source":"import"}]},{"name":"尺码","role":"size","values":[{"name":"M","source":"import"}]}]`, + } + if err := db.Create(&shopee).Error; err != nil { + t.Fatal(err) + } + syb := models.SYBProduct{ + OrderCode: "TEST-ORDER", DetailID: 1, StockID: 1, ShopeeItemID: shopee.ShopeeItemID, + ShopeeProductID: &shopee.ID, TargetColor: "黑色", TargetSize: "M", + Quantity: 1, UnitPriceCent: 1000, ParseStatus: "success", RawJSON: "{}", + } + if err := db.Create(&syb).Error; err != nil { + t.Fatal(err) + } + snapshot := ImageSearchSnapshot{ + ShopeeProductID: shopee.ID, RepresentativeSYBProductID: syb.ID, SYBProductIDs: []uint64{syb.ID}, + ReferenceCurrency: "TWD", ReferencePriceCent: 1000, MaxPriceRatio: 3, PriceGuardSkipped: true, + ImageSearchImage: ImageSearchImage{ImageURL: "https://example.invalid/a.jpg", MediaType: "image/jpeg", SizeBytes: 1, SHA256: "0123456789012345678901234567890101234567890123456789012345678901"}, + } + raw, err := json.Marshal(snapshot) + if err != nil { + t.Fatal(err) + } + if err := db.Model(&record).Updates(map[string]any{"source": models.CollectionTaskSourceImageSearch, "image_search_snapshot": string(raw)}).Error; err != nil { + t.Fatal(err) + } + return record, shopee +} + +func TestAutoLinkImageSearchPartialMissingWhitelist(t *testing.T) { + cases := []struct { + name, status, missing string + nilMissing, want bool + }{ + {name: "completed_empty", status: models.TaskStatusCompleted, missing: "[]", want: true}, + {name: "completed_nil", status: models.TaskStatusCompleted, nilMissing: true, want: true}, + {name: "nil", nilMissing: true}, + {name: "blank"}, + {name: "null", missing: "null"}, + {name: "empty", missing: "[]"}, + {name: "malformed", missing: `["reviewCount"`}, + {name: "object", missing: `{ "reviewCount": true }`}, + {name: "scalar", missing: `"reviewCount"`}, + {name: "number_entry", missing: `["reviewCount",1]`}, + {name: "null_entry", missing: `["reviewCount",null]`}, + {name: "blank_entry", missing: `[""]`}, + {name: "unknown", missing: `["reviewCount","futureField"]`}, + {name: "case_mismatch", missing: `["ReviewCount"]`}, + {name: "whitespace", missing: `[" reviewCount "]`}, + {name: "color", missing: `["reviewCount","color"]`}, + {name: "size", missing: `["size"]`}, + {name: "price", missing: `["price"]`}, + {name: "color_price", missing: `["reviewCount","price:黑色"]`}, + {name: "unsupported_dimension", missing: `["unsupportedDimension:款式"]`}, + {name: "invalid_dimension", missing: `["spec_dimension_invalid:size"]`}, + {name: "pending", status: models.TaskStatusPending, missing: `["reviewCount"]`}, + {name: "failed", status: models.TaskStatusFailed, missing: `["reviewCount"]`}, + {name: "duplicate_optional", missing: `["reviewCount","reviewCount"]`, want: true}, + } + fields := []string{"reviewCount", "salesText", "shopName"} + for mask := 1; mask < 8; mask++ { + var missing []string + for i, field := range fields { + if mask&(1<