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:
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user