From 41996f33b215f49fcdae3e0522dd6c50f4c26ca4 Mon Sep 17 00:00:00 2001 From: QiuSW Date: Tue, 15 Sep 2026 17:51:42 +0800 Subject: [PATCH] =?UTF-8?q?feat(server):=20=E5=9B=BE=E6=90=9C=E9=87=87?= =?UTF-8?q?=E9=9B=86=E6=88=90=E5=8A=9F=E5=90=8E=E8=87=AA=E5=8A=A8=E8=A7=A6?= =?UTF-8?q?=E5=8F=91=E8=A7=84=E6=A0=BC=E5=8C=B9=E9=85=8D=20(#287)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 图搜是异步的:Agent 在手机上跑几分钟,采购员必须离开页面、稍后回来、 重新勾选、再点 AI 匹配。旧流程虽然全手工但一气呵成,图搜把它切成了 两段,中间靠人记着回来。本次在关联写入之后接一个钩子消除这个断层。 `[必须]` 只能接在这里。图搜批量结果对话框在任务创建后立刻弹出,那时 Agent 一个都还没执行,无从匹配。 autoLinkImageSearch 改为返回是否确实写入关联及受影响的 SYB 明细。 RowsAffected 为 0 表示乐观并发谓词拒写——任务创建后有人改过这条关联, 目标商品已不是我们找到的那个,此时匹配上去只会把错误结果写进档案, 因此不触发。 匹配失败只记录不冒泡:采集结果已经提交完成,不能因为这个可选增强让 SubmitResult 失败、让 Agent 以为采集没成功。 不自动创建采购。匹配只把明细推到“采购就绪”,创建采购仍由人点击——那是 整条链上唯一的人工检查点,因为图搜找商品、自动关联、AI 匹配规格三步 都没有人看过,而 #200 已取消 SYB 批量入口的置信度门槛,不能以“AI 没 把握会停下”来兜底。 标记文案相应扩展为“商品由图搜找到、规格由 AI 自动匹配,均未经人工 确认”,覆盖 SYB 商品页、采购任务列表与详情、虾皮商品详情抽屉。 app/goauto/sybimport 的 12 个失败先于本次存在(#285),未新增。 Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01NTDbDcwbDw1TSAcE6wfh2F --- server/app/goauto/task/image_search_link.go | 62 +++++++++++++++---- .../app/goauto/task/image_search_link_test.go | 32 +++++++++- server/app/goauto/task/result_service.go | 4 +- web/src/views/goauto/purchase-tasks/index.vue | 4 +- .../ShopeeProductDetailDrawer.vue | 2 +- web/src/views/goauto/syb-products/index.vue | 4 +- 6 files changed, 89 insertions(+), 19 deletions(-) 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 @@