diff --git a/server/app/goauto/task/image_search_link.go b/server/app/goauto/task/image_search_link.go index 5cdf7e2..bcacd01 100644 --- a/server/app/goauto/task/image_search_link.go +++ b/server/app/goauto/task/image_search_link.go @@ -50,9 +50,18 @@ func (service *Service) autoLinkImageSearch(ctx context.Context, taskID uint64) if pdd.Status != "active" { return nil } - result := service.DB.WithContext(ctx).Model(&models.ShopeeProduct{}). - Where("id = ? AND pdd_product_id IS NULL AND image_search_linked = ?", product.ID, false). - Updates(map[string]any{"pdd_product_id": *task.PDDProductID, "image_search_linked": true}) + // `[必须]` 乐观并发,而不是“必须为空”。#280 确认后允许覆盖已有关联, + // 所以不能再要求 pdd_product_id IS NULL;但直接去掉谓词会让 Agent 执行的 + // 20-40 秒里采购员的手动改动被静默吞掉。改为比对快照里的 + // OriginalPDDProductID:只要任务创建之后没人动过这条关联就写入,动过就放弃。 + // 人仍然胜出,只是不再因为“本来就有关联”而拒写。 + query := service.DB.WithContext(ctx).Model(&models.ShopeeProduct{}).Where("id = ?", product.ID) + if snapshot.OriginalPDDProductID == nil { + query = query.Where("pdd_product_id IS NULL") + } else { + query = query.Where("pdd_product_id = ?", *snapshot.OriginalPDDProductID) + } + result := query.Updates(map[string]any{"pdd_product_id": *task.PDDProductID, "image_search_linked": true}) if result.Error != nil { return internalError(result.Error) } diff --git a/server/app/goauto/task/image_search_link_test.go b/server/app/goauto/task/image_search_link_test.go index 34c39cb..d07c53f 100644 --- a/server/app/goauto/task/image_search_link_test.go +++ b/server/app/goauto/task/image_search_link_test.go @@ -7,6 +7,8 @@ import ( "testing" "go-admin/app/goauto/models" + + "gorm.io/gorm" ) func TestImageSearchPriceAllowedRefusesCrossCurrency(t *testing.T) { @@ -44,27 +46,29 @@ func containsAny(value string, needles ...string) bool { return false } -func TestAutoLinkImageSearchDoesNotOverwriteManualAssociation(t *testing.T) { - db := openTaskDatabase(t) - manualPDD := models.PDDProduct{GoodsID: "manual-pdd", URL: "https://mobile.yangkeduo.com/goods.html?goods_id=manual-pdd", Status: "active"} - autoPDD := models.PDDProduct{GoodsID: "auto-pdd", URL: "https://mobile.yangkeduo.com/goods.html?goods_id=auto-pdd", Status: "active"} - if err := db.Create(&manualPDD).Error; err != nil { - t.Fatal(err) - } - if err := db.Create(&autoPDD).Error; err != nil { - t.Fatal(err) - } - shopee := models.ShopeeProduct{ShopeeItemID: "auto-link-test", Title: "test", Currency: "CNY", SpecsJSON: "[]", PDDProductID: &manualPDD.ID} +// seedAutoLinkCase 建一个「蝦皮商品当前关联 current,图搜任务找到 found, +// 任务创建时快照记录的关联是 original」的场景。 +func seedAutoLinkCase(t *testing.T, db *gorm.DB, itemID string, current *uint64, original *uint64, found uint64) models.ShopeeProduct { + t.Helper() + shopee := models.ShopeeProduct{ShopeeItemID: itemID, Title: "test", Currency: "CNY", SpecsJSON: "[]", PDDProductID: current} if err := db.Create(&shopee).Error; err != nil { t.Fatal(err) } - rule := models.CollectionRule{Name: "image-search-test", ContentJSON: "{}"} + rule := models.CollectionRule{Name: "image-search-" + itemID, ContentJSON: "{}"} if err := db.Create(&rule).Error; err != nil { t.Fatal(err) } - snapshot := ImageSearchSnapshot{ShopeeProductID: shopee.ID, RepresentativeSYBProductID: 1, ReferenceCurrency: "CNY", ReferencePriceCent: 100, MaxPriceRatio: 3, ImageSearchImage: ImageSearchImage{ImageURL: "https://example.invalid/a.jpg", MediaType: "image/jpeg", SizeBytes: 1, SHA256: "0123456789012345678901234567890101234567890123456789012345678901"}} + snapshot := ImageSearchSnapshot{ + ShopeeProductID: shopee.ID, RepresentativeSYBProductID: 1, OriginalPDDProductID: original, + ReferenceCurrency: "CNY", ReferencePriceCent: 100, MaxPriceRatio: 3, + ImageSearchImage: ImageSearchImage{ImageURL: "https://example.invalid/a.jpg", MediaType: "image/jpeg", SizeBytes: 1, SHA256: "0123456789012345678901234567890101234567890123456789012345678901"}, + } raw, _ := json.Marshal(snapshot) - task := models.CollectionTask{Source: models.CollectionTaskSourceImageSearch, Status: models.TaskStatusCompleted, PDDProductID: &autoPDD.ID, RuleID: rule.ID, RuleSnapshot: "{}", ImageSearchSnapshot: func() *string { v := string(raw); return &v }()} + task := models.CollectionTask{ + Source: models.CollectionTaskSourceImageSearch, Status: models.TaskStatusCompleted, + PDDProductID: &found, RuleID: rule.ID, RuleSnapshot: "{}", + ImageSearchSnapshot: func() *string { v := string(raw); return &v }(), + } if err := db.Create(&task).Error; err != nil { t.Fatal(err) } @@ -75,7 +79,74 @@ func TestAutoLinkImageSearchDoesNotOverwriteManualAssociation(t *testing.T) { if err := db.First(&saved, shopee.ID).Error; err != nil { t.Fatal(err) } - if saved.PDDProductID == nil || *saved.PDDProductID != manualPDD.ID || saved.ImageSearchLinked { - t.Fatalf("manual association was overwritten: %+v", saved) + return saved +} + +func seedAutoLinkPDD(t *testing.T, db *gorm.DB, goodsID string) models.PDDProduct { + t.Helper() + record := models.PDDProduct{GoodsID: goodsID, URL: "https://mobile.yangkeduo.com/goods.html?goods_id=" + goodsID, Status: "active"} + if err := db.Create(&record).Error; err != nil { + t.Fatal(err) + } + return record +} + +// #280:确认后允许覆盖已有关联,人工建立的也覆盖。这是用户在 2026-09-15 +// 明确选定的行为,取代了原先「pdd_product_id 必须为空才写入」的规则。 +func TestAutoLinkImageSearchOverwritesExistingAssociation(t *testing.T) { + db := openTaskDatabase(t) + existing := seedAutoLinkPDD(t, db, "existing-pdd") + found := seedAutoLinkPDD(t, db, "found-pdd") + + saved := seedAutoLinkCase(t, db, "overwrite-case", &existing.ID, &existing.ID, found.ID) + if saved.PDDProductID == nil || *saved.PDDProductID != found.ID { + t.Fatalf("existing association was not overwritten: %+v", saved.PDDProductID) + } + if !saved.ImageSearchLinked { + t.Fatal("overwritten association must be marked as image-search linked") + } +} + +// `[必须]` 覆盖是乐观并发,不是无条件。任务创建后采购员手动改了关联时, +// 人必须胜出——否则 Agent 执行的 20-40 秒会变成一个静默吞掉人工改动的窗口。 +func TestAutoLinkImageSearchSkipsWhenAssociationChangedSinceTaskCreation(t *testing.T) { + db := openTaskDatabase(t) + original := seedAutoLinkPDD(t, db, "original-pdd") + manual := seedAutoLinkPDD(t, db, "manual-pdd") + found := seedAutoLinkPDD(t, db, "found-pdd") + + // 快照记录的是 original,但落库前有人把它改成了 manual。 + saved := seedAutoLinkCase(t, db, "raced-case", &manual.ID, &original.ID, found.ID) + if saved.PDDProductID == nil || *saved.PDDProductID != manual.ID { + t.Fatalf("concurrent manual change was overwritten: %+v", saved.PDDProductID) + } + if saved.ImageSearchLinked { + t.Fatal("refused write must not flip the image-search marker") + } +} + +// 任务创建时无关联、落库前被人抢先关联,同样属于并发变化,必须放弃。 +func TestAutoLinkImageSearchSkipsWhenLinkAppearedAfterTaskCreation(t *testing.T) { + db := openTaskDatabase(t) + manual := seedAutoLinkPDD(t, db, "manual-pdd") + found := seedAutoLinkPDD(t, db, "found-pdd") + + saved := seedAutoLinkCase(t, db, "appeared-case", &manual.ID, nil, found.ID) + if saved.PDDProductID == nil || *saved.PDDProductID != manual.ID { + t.Fatalf("link created during execution was overwritten: %+v", saved.PDDProductID) + } +} + +// 常规路径:创建时无关联、落库时仍无关联。 +func TestAutoLinkImageSearchLinksWhenStillUnlinked(t *testing.T) { + db := openTaskDatabase(t) + found := seedAutoLinkPDD(t, db, "found-pdd") + + saved := seedAutoLinkCase(t, db, "fresh-case", nil, nil, found.ID) + if saved.PDDProductID == nil || *saved.PDDProductID != found.ID { + t.Fatalf("unlinked product was not linked: %+v", saved.PDDProductID) + } + if !saved.ImageSearchLinked { + t.Fatal("auto link must set the image-search marker") } } diff --git a/web/src/views/goauto/syb-products/index.vue b/web/src/views/goauto/syb-products/index.vue index 81a2368..850511a 100644 --- a/web/src/views/goauto/syb-products/index.vue +++ b/web/src/views/goauto/syb-products/index.vue @@ -116,7 +116,7 @@ - + @@ -483,7 +483,7 @@ export default { // ---------------- 批量创建 PDD 采集任务 ---------------- emptyCollectionBatch() { return { open: false, saving: false, step: 'confirm', products: [], options: { rules: [], devices: [] }, results: [], successCount: 0, failureCount: 0 } }, - emptyImageSearchBatch() { return { open: false, saving: false, step: 'confirm', rows: [], options: { rules: [], devices: [] }, ruleId: null, deviceId: null, overwriteLinked: false, results: [], successCount: 0, failureCount: 0, skippedCount: 0 } }, + emptyImageSearchBatch() { return { open: false, saving: false, step: 'confirm', rows: [], options: { rules: [], devices: [] }, ruleId: null, deviceId: null, results: [], successCount: 0, failureCount: 0, skippedCount: 0 } }, async openImageSearchBatch() { if (!this.imageSearchRows.length) return this.imageSearchBatch = { ...this.emptyImageSearchBatch(), open: true, rows: [...this.imageSearchRows], deviceId: readPurchaseDevice(this.$store.getters.userId) } @@ -493,10 +493,24 @@ export default { resetImageSearchBatch() { this.imageSearchBatch = this.emptyImageSearchBatch() }, async submitImageSearchBatch() { if (this.imageSearchBatch.saving || !this.imageSearchBatch.ruleId || this.imageSearchBatchOverLimit) return + // 已关联的商品会被覆盖,先拿到具体条数再问;一条都没有时不弹窗, + // 避免把确认框变成每次都要点掉的噪声。 + const linked = this.imageSearchBatch.rows.filter(row => row.pddGoodsId || row.pddProductId) + if (linked.length) { + try { + await this.$confirm( + `所选商品中有 ${linked.length} 个已关联 PDD 商品。继续后,图搜找到的商品将覆盖这些已有关联,包括人工建立的。确定继续?`, + '确认覆盖已有关联', + { type: 'warning', confirmButtonText: '确定覆盖', cancelButtonText: '取消', confirmButtonClass: 'el-button--danger' } + ) + } catch (e) { + return + } + } this.imageSearchBatch.saving = true try { if (this.imageSearchBatch.deviceId) rememberPurchaseDevice(this.$store.getters.userId, this.imageSearchBatch.deviceId) - const response = await batchCreateImageSearchCollectionTasks({ requestId: createRequestId(), sybProductIds: this.imageSearchBatch.rows.map(row => row.id), ruleId: this.imageSearchBatch.ruleId, deviceId: this.imageSearchBatch.deviceId || null, overwriteLinked: this.imageSearchBatch.overwriteLinked }) + const response = await batchCreateImageSearchCollectionTasks({ requestId: createRequestId(), sybProductIds: this.imageSearchBatch.rows.map(row => row.id), ruleId: this.imageSearchBatch.ruleId, deviceId: this.imageSearchBatch.deviceId || null, overwriteLinked: true }) this.imageSearchBatch.results = response.data.items || [] this.imageSearchBatch.successCount = response.data.successCount || 0 this.imageSearchBatch.failureCount = response.data.failureCount || 0