From 750b8763dc341f55120b0c3fbb2edf8b0e53aa8c Mon Sep 17 00:00:00 2001 From: QiuSW Date: Tue, 15 Sep 2026 15:23:34 +0800 Subject: [PATCH] =?UTF-8?q?feat(#280):=20=E5=9B=BE=E6=90=9C=E7=A1=AE?= =?UTF-8?q?=E8=AE=A4=E5=90=8E=E8=A6=86=E7=9B=96=E5=B7=B2=E6=9C=89=E5=85=B3?= =?UTF-8?q?=E8=81=94=EF=BC=8C=E5=8E=BB=E6=8E=89=E8=A6=86=E7=9B=96=E5=A4=8D?= =?UTF-8?q?=E9=80=89=E6=A1=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 用户 2026-09-15 选定行为:去掉「覆盖已有关联」复选框,点击创建时若 所选商品中存在已关联的,弹窗说明将被覆盖,确认后覆盖。 原复选框是误导的:它只放行任务创建,落库时的 CAS 谓词仍要求 pdd_product_id IS NULL,所以勾上之后设备白跑 20-40 秒、关联一点不变, 界面还显示任务已完成。 服务端把关联写入从「必须为空」改为乐观并发:比对快照里的 OriginalPDDProductID,只要任务创建后没人动过这条关联就写入。直接去掉 谓词会让 Agent 执行的 20-40 秒变成静默吞掉人工改动的窗口;改为乐观并发 既满足覆盖需求,又让并发的人工改动继续胜出。该快照字段与 sameImageSearchLink 早已存在,此前未被关联路径使用。 前端确认框只在确实存在已关联商品时弹出并给出条数,一条都没有时不弹, 避免变成每次都要点掉的噪声;取消则整批不创建。 测试重写为四个用例:未变更时覆盖、任务创建后被改则放弃、创建时无关联 但执行中被抢先关联则放弃、常规无关联路径正常写入。原 TestAutoLinkImageSearchDoesNotOverwriteManualAssociation 断言的是被本次 取代的旧契约,已移除。 Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01NTDbDcwbDw1TSAcE6wfh2F --- server/app/goauto/task/image_search_link.go | 15 ++- .../app/goauto/task/image_search_link_test.go | 103 +++++++++++++++--- web/src/views/goauto/syb-products/index.vue | 20 +++- 3 files changed, 116 insertions(+), 22 deletions(-) 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