feat(server): 图搜采集成功后自动触发规格匹配 (#287)

图搜是异步的: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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NTDbDcwbDw1TSAcE6wfh2F
This commit is contained in:
QiuSW
2026-09-15 17:51:42 +08:00
co-authored by Claude Opus 5
parent 2193762cbd
commit 41996f33b2
6 changed files with 89 additions and 19 deletions
+51 -11
View File
@@ -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
}
@@ -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")
}
}
+3 -1
View File
@@ -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
@@ -19,7 +19,7 @@
<el-table-column label="任务" width="105"><template #default="{ row }"><div class="primary">CG-{{ row.id }}</div><el-tag size="small" effect="plain" :type="row.executionMode === 'live' ? 'success' : 'info'">{{ modeLabel(row.executionMode) }}</el-tag></template></el-table-column>
<el-table-column label="类型" width="110"><template #default="{ row }"><el-tag size="small" effect="plain" :type="taskTypeTag(row.taskType)">{{ taskTypeLabel(row.taskType) }}</el-tag></template></el-table-column>
<el-table-column label="蝦皮订单号" min-width="160"><template #default="{ row }"><span class="order-number">{{ row.shopeeOrderNoSnapshot || '—' }}</span></template></el-table-column>
<el-table-column label="SYB / 虾皮商品" min-width="220"><template #default="{ row }"><template v-if="row.taskType === 'stock'"><div class="muted">—</div></template><template v-else><div class="primary ellipsis">{{ row.shopeeTitleSnapshot || `虾皮商品 ${row.shopeeItemIdSnapshot || '—'}` }}<el-tag v-if="row.imageSearchLinked" type="warning" size="small" effect="plain" class="image-search-tag">图搜未核</el-tag></div><div class="muted">SYB-{{ row.sybProductId || '—' }} · {{ row.shopeeShopNameSnapshot || '店铺未记录' }}</div></template></template></el-table-column>
<el-table-column label="SYB / 虾皮商品" min-width="220"><template #default="{ row }"><template v-if="row.taskType === 'stock'"><div class="muted">—</div></template><template v-else><div class="primary ellipsis">{{ row.shopeeTitleSnapshot || `虾皮商品 ${row.shopeeItemIdSnapshot || '—'}` }}<el-tag v-if="row.imageSearchLinked" type="warning" size="small" effect="plain" class="image-search-tag" title="商品由图搜找到、规格由 AI 自动匹配,均未经人工确认">图搜未核</el-tag></div><div class="muted">SYB-{{ row.sybProductId || '—' }} · {{ row.shopeeShopNameSnapshot || '店铺未记录' }}</div></template></template></el-table-column>
<el-table-column label="PDD 商品" min-width="190"><template #default="{ row }"><div class="primary ellipsis">{{ row.pddTitleSnapshot || '标题未记录' }}</div><div class="muted">goods_id {{ row.pddGoodsIdSnapshot || '—' }}</div></template></el-table-column>
<el-table-column label="采购规格 / 数量" min-width="190"><template #default="{ row }"><div>{{ specText(row.mappedColorSnapshot, row.mappedSizeSnapshot) }}</div><div class="muted">目标:{{ specText(row.targetColorSnapshot, row.targetSizeSnapshot) }} · {{ row.quantity }} 件</div></template></el-table-column>
<el-table-column label="设备" min-width="140"><template #default="{ row }"><div>{{ row.deviceName || '尚未分派' }}</div><div class="muted">账号:{{ row.pddAccountRefSnapshot || '不强制记录' }}</div></template></el-table-column>
@@ -42,7 +42,7 @@
<section><h3>任务快照</h3><el-descriptions :column="2" border>
<el-descriptions-item label="任务类型">{{ taskTypeLabel(detail.task.taskType) }}</el-descriptions-item><el-descriptions-item label="SYB 商品">{{ detail.task.taskType === 'stock' ? '—' : `SYB-${detail.task.sybProductId || '—'}` }}</el-descriptions-item>
<el-descriptions-item label="蝦皮订单号">{{ detail.task.taskType === 'stock' ? '—' : (detail.task.shopeeOrderNoSnapshot || '—') }}</el-descriptions-item><el-descriptions-item label="虾皮商品 ID">{{ detail.task.taskType === 'stock' ? '—' : (detail.task.shopeeItemIdSnapshot || '—') }}</el-descriptions-item>
<el-descriptions-item label="虾皮店铺">{{ detail.task.taskType === 'stock' ? '—' : (detail.task.shopeeShopNameSnapshot || '未记录') }}</el-descriptions-item><el-descriptions-item label="虾皮标题">{{ detail.task.taskType === 'stock' ? '—' : (detail.task.shopeeTitleSnapshot || '未记录') }}<el-tag v-if="detail.task.imageSearchLinked" type="warning" size="small" effect="plain" class="image-search-tag">图搜未核</el-tag></el-descriptions-item><el-descriptions-item label="PDD goods_id">{{ detail.task.pddGoodsIdSnapshot || '—' }}</el-descriptions-item>
<el-descriptions-item label="虾皮店铺">{{ detail.task.taskType === 'stock' ? '—' : (detail.task.shopeeShopNameSnapshot || '未记录') }}</el-descriptions-item><el-descriptions-item label="虾皮标题">{{ detail.task.taskType === 'stock' ? '—' : (detail.task.shopeeTitleSnapshot || '未记录') }}<el-tag v-if="detail.task.imageSearchLinked" type="warning" size="small" effect="plain" class="image-search-tag" title="商品由图搜找到、规格由 AI 自动匹配,均未经人工确认">图搜未核</el-tag></el-descriptions-item><el-descriptions-item label="PDD goods_id">{{ detail.task.pddGoodsIdSnapshot || '—' }}</el-descriptions-item>
<el-descriptions-item label="PDD 标题" :span="2">{{ detail.task.pddTitleSnapshot || '未记录' }}</el-descriptions-item>
<el-descriptions-item label="目标规格">{{ specText(detail.task.targetColorSnapshot, detail.task.targetSizeSnapshot) }}</el-descriptions-item><el-descriptions-item label="执行规格">{{ specText(detail.task.mappedColorSnapshot, detail.task.mappedSizeSnapshot) }}</el-descriptions-item>
<el-descriptions-item label="数量">{{ detail.task.quantity }} 件</el-descriptions-item><el-descriptions-item label="映射来源">{{ specSourceLabel(detail.task.specSource) }} <el-button v-if="detail.task.shopeeProductId" type="primary" link @click="openShopeeMapping">查看映射</el-button></el-descriptions-item>
@@ -13,7 +13,7 @@
<template #default><el-button link type="primary" @click="reload">重新加载</el-button></template>
</el-alert>
<template v-if="detail.product">
<div class="drawer-actions"><div class="drawer-status"><el-tag :type="mappingMeta(detail.product).type">{{ mappingMeta(detail.product).label }}</el-tag><el-tag v-if="detail.product.imageSearchLinked" type="success" size="small">图搜自动关联</el-tag></div><el-button type="primary" :disabled="detail.aiMatching" @click="openEdit">编辑档案</el-button></div>
<div class="drawer-actions"><div class="drawer-status"><el-tag :type="mappingMeta(detail.product).type">{{ mappingMeta(detail.product).label }}</el-tag><el-tag v-if="detail.product.imageSearchLinked" type="success" size="small" title="商品由图搜找到、规格由 AI 自动匹配,均未经人工确认">图搜自动关联</el-tag></div><el-button type="primary" :disabled="detail.aiMatching" @click="openEdit">编辑档案</el-button></div>
<el-descriptions :column="2" border :inert="detail.aiMatching">
<el-descriptions-item label="虾皮商品ID">{{ detail.product.shopeeItemId }}</el-descriptions-item>
<el-descriptions-item label="店铺">{{ detail.product.shopName || '未填写' }}</el-descriptions-item>
+2 -2
View File
@@ -77,7 +77,7 @@
<el-dialog v-model="purchaseDialog.open" title="批量创建采购任务" width="900px" :close-on-click-modal="false">
<div v-loading="purchaseDialog.loading">
<el-alert title="每条 SYB 商品分别创建一个采购任务和一个待付款 PDD 订单;系统永不支付。创建前服务端会再次逐条检查。" type="warning" :closable="false" show-icon class="notice" />
<div class="purchase-summary" aria-live="polite"><span>已选择 <strong>{{ purchaseDialog.selectedCount }}</strong> 条 SYB 明细</span><span class="success-text">其中 {{ purchaseDialog.eligibleCount }} 条适用于采购</span><span v-if="purchaseDialog.skippedCount" class="danger-text">预检后另有 {{ purchaseDialog.skippedCount }} 条不可创建</span><span v-if="purchaseImageSearchLinkedCount" class="warning-text">其中 {{ purchaseImageSearchLinkedCount }} 条为图搜自动关联,尚未人工核对</span></div>
<div class="purchase-summary" aria-live="polite"><span>已选择 <strong>{{ purchaseDialog.selectedCount }}</strong> 条 SYB 明细</span><span class="success-text">其中 {{ purchaseDialog.eligibleCount }} 条适用于采购</span><span v-if="purchaseDialog.skippedCount" class="danger-text">预检后另有 {{ purchaseDialog.skippedCount }} 条不可创建</span><span v-if="purchaseImageSearchLinkedCount" class="warning-text">其中 {{ purchaseImageSearchLinkedCount }} 条由图搜找商品、AI 自动匹配规格,均未经人工核对</span></div>
<el-form label-width="110px" class="purchase-settings">
<el-form-item label="Android 设备">
<el-select v-model="purchaseDialog.deviceId" clearable placeholder="不指定,由空闲设备领取" style="width:360px" :disabled="purchaseDialog.saving" @change="changePurchaseDevice">
@@ -91,7 +91,7 @@
<el-alert v-if="purchaseDialog.previewError" :title="purchaseDialog.previewError" type="error" :closable="false" show-icon class="notice"><el-button link type="primary" @click="openPurchaseBatch">重试</el-button></el-alert>
<el-table :data="purchaseDialog.items" border size="small" max-height="380" empty-text="没有可创建的商品">
<el-table-column label="SYB 商品" min-width="150"><template #default="{ row }"><strong>{{ row.orderCode || `ID ${row.sybProductId}` }}</strong><div class="muted">{{ row.shopeeItemId || '未关联蝦皮商品' }}</div></template></el-table-column>
<el-table-column label="商品" min-width="210"><template #default="{ row }"><div class="ellipsis">{{ row.productTitle || '—' }}<el-tag v-if="row.imageSearchLinked" type="warning" size="small" effect="plain" class="image-search-tag">图搜未核</el-tag></div><div class="muted">PDD {{ row.pddGoodsId || '未关联' }}</div></template></el-table-column>
<el-table-column label="商品" min-width="210"><template #default="{ row }"><div class="ellipsis">{{ row.productTitle || '—' }}<el-tag v-if="row.imageSearchLinked" type="warning" size="small" effect="plain" class="image-search-tag" title="商品由图搜找到、规格由 AI 自动匹配,均未经人工确认">图搜未核</el-tag></div><div class="muted">PDD {{ row.pddGoodsId || '未关联' }}</div></template></el-table-column>
<el-table-column label="目标规格 / 数量" min-width="170"><template #default="{ row }">{{ row.targetColor || '—' }} / {{ row.targetSize || '—' }} ×{{ row.quantity || 0 }}</template></el-table-column>
<el-table-column label="检查结果" min-width="240"><template #default="{ row }"><template v-if="row.eligible"><el-tag type="success">可以创建</el-tag><div class="muted">{{ purchasePriceText(row) }}</div></template><template v-else><el-tag type="warning">跳过</el-tag><div class="purchase-reason">{{ row.reason }}</div></template></template></el-table-column>
</el-table>