feat(#280): 图搜确认后覆盖已有关联,去掉覆盖复选框

用户 2026-09-15 选定行为:去掉「覆盖已有关联」复选框,点击创建时若
所选商品中存在已关联的,弹窗说明将被覆盖,确认后覆盖。

原复选框是误导的:它只放行任务创建,落库时的 CAS 谓词仍要求
pdd_product_id IS NULL,所以勾上之后设备白跑 20-40 秒、关联一点不变,
界面还显示任务已完成。

服务端把关联写入从「必须为空」改为乐观并发:比对快照里的
OriginalPDDProductID,只要任务创建后没人动过这条关联就写入。直接去掉
谓词会让 Agent 执行的 20-40 秒变成静默吞掉人工改动的窗口;改为乐观并发
既满足覆盖需求,又让并发的人工改动继续胜出。该快照字段与
sameImageSearchLink 早已存在,此前未被关联路径使用。

前端确认框只在确实存在已关联商品时弹出并给出条数,一条都没有时不弹,
避免变成每次都要点掉的噪声;取消则整批不创建。

测试重写为四个用例:未变更时覆盖、任务创建后被改则放弃、创建时无关联
但执行中被抢先关联则放弃、常规无关联路径正常写入。原
TestAutoLinkImageSearchDoesNotOverwriteManualAssociation 断言的是被本次
取代的旧契约,已移除。

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 15:23:34 +08:00
co-authored by Claude Opus 5
parent b4393dac84
commit 750b8763dc
3 changed files with 116 additions and 22 deletions
+12 -3
View File
@@ -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)
}
@@ -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")
}
}
+17 -3
View File
@@ -116,7 +116,7 @@
<!-- 批量创建 PDD 采集任务 -->
<el-dialog v-model="imageSearchBatch.open" :title="imageSearchBatch.step === 'confirm' ? '批量图搜采集' : '图搜采集结果'" width="760px" :close-on-click-modal="false" @closed="resetImageSearchBatch">
<template v-if="imageSearchBatch.step === 'confirm'"><el-alert title="每个蝦皮商品只创建一个图搜采集任务;参考图由服务端校验,已关联商品默认跳过。" type="info" :closable="false" show-icon class="notice" /><el-alert :title="imageSearchBatchOverLimit ? `本次去重后将创建 ${imageSearchBatchTaskCount} 个图搜任务,超过单批上限 ${imageSearchMaxBatchTasks} 个,请减少勾选后重试` : `本次去重后将创建 ${imageSearchBatchTaskCount} 个图搜任务,预计占用设备 ${imageSearchBatchDurationText}(按每个任务 20~40 秒估算)`" :type="imageSearchBatchOverLimit ? 'error' : 'warning'" :closable="false" show-icon class="notice" /><el-form label-position="top"><el-form-item label="采集规则"><el-select v-model="imageSearchBatch.ruleId" filterable style="width:100%" placeholder="请选择采集规则"><el-option v-for="item in imageSearchBatch.options.rules" :key="item.id" :label="item.name" :value="item.id" /></el-select></el-form-item><el-form-item label="Android 设备"><el-select v-model="imageSearchBatch.deviceId" clearable style="width:100%" placeholder="不指定,由支持图搜的空闲设备领取"><el-option v-for="item in imageSearchBatch.options.devices" :key="item.id" :label="`${item.name} · ${item.model}`" :value="item.id" /></el-select></el-form-item><el-checkbox v-model="imageSearchBatch.overwriteLinked">覆盖已有关联</el-checkbox></el-form><el-table :data="imageSearchBatch.rows" border size="small" max-height="300"><el-table-column label="订单号" prop="orderCode" width="160" /><el-table-column label="蝦皮商品" prop="shopeeItemId" min-width="180" /><el-table-column label="参考图" min-width="260"><template #default="{ row }"><el-image :src="row.imageUrl" fit="cover" style="width:48px;height:48px" /><span class="muted">{{ row.imageUrl }}</span></template></el-table-column></el-table></template>
<template v-if="imageSearchBatch.step === 'confirm'"><el-alert title="每个蝦皮商品只创建一个图搜采集任务;参考图由服务端校验,已关联的商品会在确认后被图搜结果覆盖。" type="info" :closable="false" show-icon class="notice" /><el-alert :title="imageSearchBatchOverLimit ? `本次去重后将创建 ${imageSearchBatchTaskCount} 个图搜任务,超过单批上限 ${imageSearchMaxBatchTasks} 个,请减少勾选后重试` : `本次去重后将创建 ${imageSearchBatchTaskCount} 个图搜任务,预计占用设备 ${imageSearchBatchDurationText}(按每个任务 20~40 秒估算)`" :type="imageSearchBatchOverLimit ? 'error' : 'warning'" :closable="false" show-icon class="notice" /><el-form label-position="top"><el-form-item label="采集规则"><el-select v-model="imageSearchBatch.ruleId" filterable style="width:100%" placeholder="请选择采集规则"><el-option v-for="item in imageSearchBatch.options.rules" :key="item.id" :label="item.name" :value="item.id" /></el-select></el-form-item><el-form-item label="Android 设备"><el-select v-model="imageSearchBatch.deviceId" clearable style="width:100%" placeholder="不指定,由支持图搜的空闲设备领取"><el-option v-for="item in imageSearchBatch.options.devices" :key="item.id" :label="`${item.name} · ${item.model}`" :value="item.id" /></el-select></el-form-item></el-form><el-table :data="imageSearchBatch.rows" border size="small" max-height="300"><el-table-column label="订单号" prop="orderCode" width="160" /><el-table-column label="蝦皮商品" prop="shopeeItemId" min-width="180" /><el-table-column label="参考图" min-width="260"><template #default="{ row }"><el-image :src="row.imageUrl" fit="cover" style="width:48px;height:48px" /><span class="muted">{{ row.imageUrl }}</span></template></el-table-column></el-table></template>
<template v-else><el-alert :title="`完成:成功 ${imageSearchBatch.successCount},失败 ${imageSearchBatch.failureCount},跳过 ${imageSearchBatch.skippedCount}`" :type="imageSearchBatch.failureCount ? 'warning' : 'success'" :closable="false" show-icon class="notice" /><el-table :data="imageSearchBatch.results" border size="small" max-height="340"><el-table-column label="蝦皮商品" prop="shopeeProductId" width="130" /><el-table-column label="结果" width="100"><template #default="{ row }"><el-tag :type="row.success ? 'success' : row.code === 'IMAGE_SEARCH_ALREADY_LINKED' ? 'info' : 'danger'">{{ row.success ? '已创建' : row.code === 'IMAGE_SEARCH_ALREADY_LINKED' ? '已跳过' : '失败' }}</el-tag></template></el-table-column><el-table-column label="任务 / 原因" min-width="260"><template #default="{ row }">{{ row.success ? `任务 #${row.taskId}` : row.message }}</template></el-table-column></el-table></template>
<template #footer><el-button @click="imageSearchBatch.open = false">{{ imageSearchBatch.step === 'confirm' ? '取消' : '关闭' }}</el-button><el-button v-if="imageSearchBatch.step === 'confirm'" type="primary" :loading="imageSearchBatch.saving" :disabled="!imageSearchBatch.ruleId || !imageSearchBatch.rows.length || imageSearchBatchOverLimit" @click="submitImageSearchBatch">创建图搜任务</el-button></template>
</el-dialog>
@@ -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