diff --git a/server/app/goauto/task/image_search_link.go b/server/app/goauto/task/image_search_link.go index ec34eb5..6f1b8a2 100644 --- a/server/app/goauto/task/image_search_link.go +++ b/server/app/goauto/task/image_search_link.go @@ -3,6 +3,9 @@ package task import ( "context" "errors" + "log" + + "go-admin/app/goauto/purchase" "go-admin/app/goauto/models" "gorm.io/gorm" @@ -12,17 +15,24 @@ import ( // writes an association when the task was a complete collection, 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. -func (service *Service) autoLinkImageSearch(ctx context.Context, taskID uint64) error { +// autoLinkedImageSearch 汇报本次是否确实写入了关联,以及关联覆盖了哪些 SYB 明细。 +// 只有确实写入时才值得触发后续的规格匹配(#287)。 +type autoLinkedImageSearch struct { + Linked bool + SYBProductIDs []uint64 +} + +func (service *Service) autoLinkImageSearch(ctx context.Context, taskID uint64) (autoLinkedImageSearch, error) { var task models.CollectionTask if err := service.DB.WithContext(ctx).First(&task, taskID).Error; err != nil { - return internalError(err) + return autoLinkedImageSearch{}, internalError(err) } if task.Source != models.CollectionTaskSourceImageSearch || task.Status != models.TaskStatusCompleted || task.PDDProductID == nil || task.ImageSearchSnapshot == nil { - return nil + return autoLinkedImageSearch{}, nil } snapshot, err := loadImageSearchSnapshot(task) if err != nil { - return err + return autoLinkedImageSearch{}, err } // `[必须]` 币种不同只说明**价格没法比**,不说明这条关联不该建立。这两件事 // 此前被混在一起:snapshot.PriceGuardSkipped 与 shopee_product.currency != "CNY" @@ -39,19 +49,19 @@ func (service *Service) autoLinkImageSearch(ctx context.Context, taskID uint64) var product models.ShopeeProduct if err := service.DB.WithContext(ctx).First(&product, snapshot.ShopeeProductID).Error; err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { - return nil + return autoLinkedImageSearch{}, nil } - return internalError(err) + return autoLinkedImageSearch{}, internalError(err) } var pdd models.PDDProduct if err := service.DB.WithContext(ctx).Select("id", "status").First(&pdd, *task.PDDProductID).Error; err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { - return nil + return autoLinkedImageSearch{}, nil } - return internalError(err) + return autoLinkedImageSearch{}, internalError(err) } if pdd.Status != "active" { - return nil + return autoLinkedImageSearch{}, nil } // `[必须]` 乐观并发,而不是“必须为空”。#280 确认后允许覆盖已有关联, // 所以不能再要求 pdd_product_id IS NULL;但直接去掉谓词会让 Agent 执行的 @@ -66,7 +76,37 @@ func (service *Service) autoLinkImageSearch(ctx context.Context, taskID uint64) } result := query.Updates(map[string]any{"pdd_product_id": *task.PDDProductID, "image_search_linked": true}) if result.Error != nil { - return internalError(result.Error) + return autoLinkedImageSearch{}, internalError(result.Error) + } + // RowsAffected 为 0 表示乐观并发谓词拒绝了写入——任务创建后有人改过这条 + // 关联。此时不应触发规格匹配:匹配的目标商品并不是我们刚找到的那个。 + if result.RowsAffected == 0 { + return autoLinkedImageSearch{}, nil + } + return autoLinkedImageSearch{Linked: true, SYBProductIDs: snapshot.SYBProductIDs}, nil +} + +// matchSpecsAfterImageSearch triggers the existing SYB batch spec match for the +// details the image-search task just linked (#287). +// +// `[必须]` 这一步是为了消除图搜引入的异步断层:图搜在手机上跑几分钟,采购员 +// 必须离开页面、稍后回来、重新勾选、再点 AI 匹配。接在关联写入之后是唯一能 +// 消掉这个断层的位置——图搜批量结果对话框在任务**创建后立刻**弹出,那时 +// Agent 一个都还没执行,无从匹配。 +// +// `[必须]` 失败只记录不冒泡。采集结果已经提交完成,不能因为这个可选增强而让 +// SubmitResult 失败、让 Agent 以为采集没成功。 +// +// `[必须]` 这不会自动创建采购。匹配只把明细推到“采购就绪”,创建采购仍由人 +// 点击——那是整条链上唯一的人工检查点,因为图搜找商品、自动关联、AI 匹配规格 +// 三步都没有人看过,而 #200 已取消 SYB 批量入口的置信度门槛。 +func (service *Service) matchSpecsAfterImageSearch(ctx context.Context, linked autoLinkedImageSearch) { + if !linked.Linked || len(linked.SYBProductIDs) == 0 { + return + } + if _, err := purchase.NewService(service.DB).BatchSpecMatch(ctx, purchase.BatchSpecMatchRequest{ + SYBProductIDs: linked.SYBProductIDs, + }); err != nil { + log.Printf("image search spec match failed for syb products %v: %v", linked.SYBProductIDs, err) } - return nil } diff --git a/server/app/goauto/task/image_search_link_test.go b/server/app/goauto/task/image_search_link_test.go index 59854ce..db34bbc 100644 --- a/server/app/goauto/task/image_search_link_test.go +++ b/server/app/goauto/task/image_search_link_test.go @@ -65,7 +65,7 @@ func seedAutoLinkCaseWithCurrency(t *testing.T, db *gorm.DB, itemID string, curr t.Fatal(err) } snapshot := ImageSearchSnapshot{ - ShopeeProductID: shopee.ID, RepresentativeSYBProductID: 1, OriginalPDDProductID: original, + ShopeeProductID: shopee.ID, RepresentativeSYBProductID: 1, SYBProductIDs: []uint64{1}, OriginalPDDProductID: original, ReferenceCurrency: currency, ReferencePriceCent: 100, MaxPriceRatio: 3, PriceGuardSkipped: currency != "CNY", ImageSearchImage: ImageSearchImage{ImageURL: "https://example.invalid/a.jpg", MediaType: "image/jpeg", SizeBytes: 1, SHA256: "0123456789012345678901234567890101234567890123456789012345678901"}, @@ -79,9 +79,11 @@ func seedAutoLinkCaseWithCurrency(t *testing.T, db *gorm.DB, itemID string, curr if err := db.Create(&task).Error; err != nil { t.Fatal(err) } - if err := NewService(db).autoLinkImageSearch(context.Background(), task.ID); err != nil { + linked, err := NewService(db).autoLinkImageSearch(context.Background(), task.ID) + if err != nil { t.Fatal(err) } + lastAutoLinkResult = linked var saved models.ShopeeProduct if err := db.First(&saved, shopee.ID).Error; err != nil { t.Fatal(err) @@ -89,6 +91,10 @@ func seedAutoLinkCaseWithCurrency(t *testing.T, db *gorm.DB, itemID string, curr return saved } +// lastAutoLinkResult 记录最近一次 seedAutoLinkCase* 的关联结果,供 #287 +// 的用例断言“是否真的写入了关联”——只有写入了才该触发规格匹配。 +var lastAutoLinkResult autoLinkedImageSearch + 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"} @@ -186,3 +192,25 @@ func TestAutoLinkImageSearchStillLinksSameCurrencyProducts(t *testing.T) { t.Fatalf("CNY 商品未建立关联: %+v", saved.PDDProductID) } } + +// #287:只有关联确实写入时才该触发规格匹配。乐观并发谓词拒写时, +// 目标商品已经不是我们刚找到的那个,匹配上去只会把错误结果写进档案。 +func TestAutoLinkReportsWhetherItActuallyWrote(t *testing.T) { + db := openTaskDatabase(t) + found := seedAutoLinkPDD(t, db, "found-pdd") + + seedAutoLinkCaseWithCurrency(t, db, "linked-case", "TWD", nil, nil, found.ID) + if !lastAutoLinkResult.Linked { + t.Fatal("写入关联后必须报告 Linked=true,否则不会触发规格匹配") + } + if len(lastAutoLinkResult.SYBProductIDs) == 0 { + t.Fatal("必须带出受影响的 SYB 明细,否则匹配无从下手") + } + + manual := seedAutoLinkPDD(t, db, "manual-pdd") + original := seedAutoLinkPDD(t, db, "original-pdd") + seedAutoLinkCaseWithCurrency(t, db, "raced-287", "TWD", &manual.ID, &original.ID, found.ID) + if lastAutoLinkResult.Linked { + t.Fatal("并发人工改动胜出时不得报告 Linked=true") + } +} diff --git a/server/app/goauto/task/result_service.go b/server/app/goauto/task/result_service.go index 8fdd34e..a49551c 100644 --- a/server/app/goauto/task/result_service.go +++ b/server/app/goauto/task/result_service.go @@ -123,9 +123,11 @@ func (service *Service) SubmitResult(ctx context.Context, taskID uint64, request if err := service.activatePersistedReplacement(ctx, taskID); err != nil { return DetailResponse{}, err } - if err := service.autoLinkImageSearch(ctx, taskID); err != nil { + linked, err := service.autoLinkImageSearch(ctx, taskID) + if err != nil { return DetailResponse{}, err } + service.matchSpecsAfterImageSearch(ctx, linked) detail, err := service.Detail(ctx, taskID) detail.Replayed = replayed return detail, err diff --git a/web/src/views/goauto/purchase-tasks/index.vue b/web/src/views/goauto/purchase-tasks/index.vue index 906404f..c50bf66 100644 --- a/web/src/views/goauto/purchase-tasks/index.vue +++ b/web/src/views/goauto/purchase-tasks/index.vue @@ -19,7 +19,7 @@ - + @@ -42,7 +42,7 @@

任务快照

{{ taskTypeLabel(detail.task.taskType) }}{{ detail.task.taskType === 'stock' ? '—' : `SYB-${detail.task.sybProductId || '—'}` }} {{ detail.task.taskType === 'stock' ? '—' : (detail.task.shopeeOrderNoSnapshot || '—') }}{{ detail.task.taskType === 'stock' ? '—' : (detail.task.shopeeItemIdSnapshot || '—') }} - {{ detail.task.taskType === 'stock' ? '—' : (detail.task.shopeeShopNameSnapshot || '未记录') }}{{ detail.task.taskType === 'stock' ? '—' : (detail.task.shopeeTitleSnapshot || '未记录') }}图搜未核{{ detail.task.pddGoodsIdSnapshot || '—' }} + {{ detail.task.taskType === 'stock' ? '—' : (detail.task.shopeeShopNameSnapshot || '未记录') }}{{ detail.task.taskType === 'stock' ? '—' : (detail.task.shopeeTitleSnapshot || '未记录') }}图搜未核{{ detail.task.pddGoodsIdSnapshot || '—' }} {{ detail.task.pddTitleSnapshot || '未记录' }} {{ specText(detail.task.targetColorSnapshot, detail.task.targetSizeSnapshot) }}{{ specText(detail.task.mappedColorSnapshot, detail.task.mappedSizeSnapshot) }} {{ detail.task.quantity }} 件{{ specSourceLabel(detail.task.specSource) }} 查看映射 diff --git a/web/src/views/goauto/shopee-products/ShopeeProductDetailDrawer.vue b/web/src/views/goauto/shopee-products/ShopeeProductDetailDrawer.vue index eb91d0f..eb9d853 100644 --- a/web/src/views/goauto/shopee-products/ShopeeProductDetailDrawer.vue +++ b/web/src/views/goauto/shopee-products/ShopeeProductDetailDrawer.vue @@ -13,7 +13,7 @@