Merge branch 'fix/b280-imagesearch' into integrate/imagesearch

This commit is contained in:
QiuSW
2026-09-15 10:36:34 +08:00
3 changed files with 183 additions and 3 deletions
+29
View File
@@ -29,6 +29,26 @@ import (
const ImageSearchCapability = "pdd.image-search.v1"
const imageSearchMaxBytes int64 = 10 << 20
// imageSearchMaxBatchTasks caps the number of *deduplicated* tasks a single
// batch can create (one task per shopee_product, after merging SYB detail
// rows). #277's scheduling floor (采购 > 采集 > 图搜) is not implemented yet,
// so an oversized image-search batch can starve manually started collection
// and purchase tasks indefinitely while it occupies the device. One image
// search takes roughly 20-40s on device, so 50 tasks bound a single batch to
// about 17-33 minutes of device time. Overridable via
// GOAUTO_IMAGE_SEARCH_MAX_BATCH_TASKS for operational tuning; see #280.
const imageSearchMaxBatchTasks = 50
func imageSearchBatchTaskLimit() int64 {
limit := int64(imageSearchMaxBatchTasks)
if value := strings.TrimSpace(os.Getenv("GOAUTO_IMAGE_SEARCH_MAX_BATCH_TASKS")); value != "" {
if parsed, err := strconv.ParseInt(value, 10, 64); err == nil && parsed >= 1 {
limit = parsed
}
}
return limit
}
func validImageMetadata(value ImageSearchImage) bool {
if value.SizeBytes <= 0 || value.SizeBytes > imageSearchMaxBytes || len(value.SHA256) != 64 || value.SHA256 != strings.ToLower(value.SHA256) || value.ImageURL == "" {
return false
@@ -150,6 +170,15 @@ func (service *Service) BatchCreateImageSearch(ctx context.Context, request Imag
response.Items = append(response.Items, ImageSearchBatchItem{ShopeeProductID: *row.ShopeeProductID, SYBProductIDs: []uint64{id}})
}
}
// Enforce the cap on deduplicated tasks (one per shopee_product), not on
// the raw SYB detail count: several SYB rows merge into one task above,
// so limiting the input list would reject batches that create far fewer
// tasks than the limit, and would allow batches that dedupe less than
// expected to slip past a limit checked before merging.
if limit := imageSearchBatchTaskLimit(); int64(len(groups)) > limit {
return ImageSearchBatchResponse{}, serviceError("IMAGE_SEARCH_BATCH_TOO_LARGE",
fmt.Sprintf("本次去重后将创建 %d 个图搜任务,超过单批上限 %d 个,请拆分批次后重试", len(groups), limit))
}
for index := range response.Items {
item := &response.Items[index]
if item.Code == "" {
@@ -0,0 +1,136 @@
package task
import (
"context"
"fmt"
"testing"
"go-admin/app/goauto/models"
"github.com/google/uuid"
)
func TestBatchCreateImageSearchWithinLimitSucceeds(t *testing.T) {
db := openTaskDatabase(t)
rule := models.CollectionRule{Name: "image-search-limit-ok", ContentJSON: v2TaskRuleSnapshot()}
if err := db.Create(&rule).Error; err != nil {
t.Fatal(err)
}
const shopeeCount = 3
var sybIDs []uint64
for i := 0; i < shopeeCount; i++ {
shopee := models.ShopeeProduct{ShopeeItemID: fmt.Sprintf("limit-ok-%d", i), Title: "t", Currency: "CNY", SpecsJSON: "[]"}
if err := db.Create(&shopee).Error; err != nil {
t.Fatal(err)
}
syb := models.SYBProduct{OrderCode: fmt.Sprintf("ORD-OK-%d", i), DetailID: uint64(i + 1), StockID: 1, ShopeeItemID: shopee.ShopeeItemID, ShopeeProductID: &shopee.ID, Quantity: 1, UnitPriceCent: 100, ImageURL: "https://example.invalid/ok.jpg", ParseStatus: "success", RawJSON: "{}"}
if err := db.Create(&syb).Error; err != nil {
t.Fatal(err)
}
sybIDs = append(sybIDs, syb.ID)
}
service := NewService(db)
service.FetchImageSearchImage = func(ctx context.Context, url string) (ImageSearchImage, error) {
return ImageSearchImage{ImageURL: url, MediaType: "image/jpeg", SizeBytes: 10, SHA256: "0123456789012345678901234567890101234567890123456789012345678901"}, nil
}
request := ImageSearchBatchRequest{RequestID: uuid.NewString(), SYBProductIDs: sybIDs, RuleID: rule.ID}
response, err := service.BatchCreateImageSearch(context.Background(), request)
if err != nil {
t.Fatalf("batch within limit should succeed: %v", err)
}
if response.SuccessCount != shopeeCount {
t.Fatalf("expected %d successes, got %d (items=%+v)", shopeeCount, response.SuccessCount, response.Items)
}
}
func TestBatchCreateImageSearchOverDedupedLimitRejected(t *testing.T) {
db := openTaskDatabase(t)
rule := models.CollectionRule{Name: "image-search-limit-over", ContentJSON: v2TaskRuleSnapshot()}
if err := db.Create(&rule).Error; err != nil {
t.Fatal(err)
}
shopeeCount := int(imageSearchMaxBatchTasks) + 1
var sybIDs []uint64
for i := 0; i < shopeeCount; i++ {
shopee := models.ShopeeProduct{ShopeeItemID: fmt.Sprintf("limit-over-%d", i), Title: "t", Currency: "CNY", SpecsJSON: "[]"}
if err := db.Create(&shopee).Error; err != nil {
t.Fatal(err)
}
syb := models.SYBProduct{OrderCode: fmt.Sprintf("ORD-OVER-%d", i), DetailID: uint64(i + 1), StockID: 1, ShopeeItemID: shopee.ShopeeItemID, ShopeeProductID: &shopee.ID, Quantity: 1, UnitPriceCent: 100, ImageURL: "https://example.invalid/over.jpg", ParseStatus: "success", RawJSON: "{}"}
if err := db.Create(&syb).Error; err != nil {
t.Fatal(err)
}
sybIDs = append(sybIDs, syb.ID)
}
service := NewService(db)
request := ImageSearchBatchRequest{RequestID: uuid.NewString(), SYBProductIDs: sybIDs, RuleID: rule.ID}
_, err := service.BatchCreateImageSearch(context.Background(), request)
if err == nil {
t.Fatal("expected batch exceeding the deduplicated task limit to be rejected")
}
target, ok := err.(*ServiceError)
if !ok {
t.Fatalf("expected *ServiceError, got %T: %v", err, err)
}
if target.Code != "IMAGE_SEARCH_BATCH_TOO_LARGE" {
t.Fatalf("unexpected error code: %s (%s)", target.Code, target.Message)
}
if target.Message == "" {
t.Fatal("error message must be human-readable, not empty")
}
var count int64
if err := db.Model(&models.CollectionTask{}).Where("source = ?", models.CollectionTaskSourceImageSearch).Count(&count).Error; err != nil {
t.Fatal(err)
}
if count != 0 {
t.Fatalf("rejected batch must not partially create tasks, found %d", count)
}
}
// TestBatchCreateImageSearchLimitAppliesAfterDedup proves the cap is checked
// against the number of *distinct shopee products* (post-dedup task count),
// not the raw SYB detail row count: many SYB rows can point at very few
// shopee products (e.g. multi-size/color orders of the same listing), and
// such a batch must succeed even though its raw input list is larger than
// the task limit.
func TestBatchCreateImageSearchLimitAppliesAfterDedup(t *testing.T) {
db := openTaskDatabase(t)
rule := models.CollectionRule{Name: "image-search-limit-dedup", ContentJSON: v2TaskRuleSnapshot()}
if err := db.Create(&rule).Error; err != nil {
t.Fatal(err)
}
// One shopee product, but more SYB detail rows than imageSearchMaxBatchTasks.
shopee := models.ShopeeProduct{ShopeeItemID: "dedup-shared", Title: "t", Currency: "CNY", SpecsJSON: "[]"}
if err := db.Create(&shopee).Error; err != nil {
t.Fatal(err)
}
detailCount := int(imageSearchMaxBatchTasks) + 5
var sybIDs []uint64
for i := 0; i < detailCount; i++ {
syb := models.SYBProduct{OrderCode: fmt.Sprintf("ORD-DEDUP-%d", i), DetailID: uint64(i + 1), StockID: 1, ShopeeItemID: shopee.ShopeeItemID, ShopeeProductID: &shopee.ID, Quantity: 1, UnitPriceCent: 100, ImageURL: "https://example.invalid/dedup.jpg", ParseStatus: "success", RawJSON: "{}"}
if err := db.Create(&syb).Error; err != nil {
t.Fatal(err)
}
sybIDs = append(sybIDs, syb.ID)
}
service := NewService(db)
service.FetchImageSearchImage = func(ctx context.Context, url string) (ImageSearchImage, error) {
return ImageSearchImage{ImageURL: url, MediaType: "image/jpeg", SizeBytes: 10, SHA256: "0123456789012345678901234567890101234567890123456789012345678901"}, nil
}
// The raw input list itself is capped at 100 SYB ids per request
// (INVALID_REQUEST check), independent of this batch-task limit. Keep
// the raw list at or under 100 while still exceeding
// imageSearchMaxBatchTasks after dedup, so this test isolates the
// post-dedup limit rather than the unrelated 100-item input cap.
if len(sybIDs) > 100 {
sybIDs = sybIDs[:100]
}
request := ImageSearchBatchRequest{RequestID: uuid.NewString(), SYBProductIDs: sybIDs, RuleID: rule.ID}
response, err := service.BatchCreateImageSearch(context.Background(), request)
if err != nil {
t.Fatalf("batch whose raw input exceeds the limit but dedupes to one task must succeed: %v", err)
}
if response.SuccessCount != 1 {
t.Fatalf("expected exactly one deduplicated task, got successCount=%d items=%+v", response.SuccessCount, response.Items)
}
}
+18 -3
View File
@@ -117,9 +117,9 @@
<!-- 批量创建 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-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-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-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" @click="submitImageSearchBatch">创建图搜任务</el-button></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>
<el-dialog v-model="collectionBatch.open" :title="collectionBatch.step === 'confirm' ? '批量创建 PDD 采集任务' : '批量创建结果'" width="780px" :close-on-click-modal="false" @closed="resetCollectionBatch">
@@ -233,6 +233,9 @@ import { readPurchaseDevice, rememberPurchaseDevice } from '@/utils/purchase-dev
import ShopeeProductDetailDrawer from '../shopee-products/ShopeeProductDetailDrawer.vue'
import PddProductDetailDrawer from '../pdd-products/PddProductDetailDrawer.vue'
// 与服务端 image_search.go 的 imageSearchMaxBatchTasks 保持一致(单批去重后任务数上限,#280)。
const IMAGE_SEARCH_MAX_BATCH_TASKS = 50
export default {
name: 'GoAutoSybProducts',
components: { ShopeeProductDetailDrawer, PddProductDetailDrawer },
@@ -276,6 +279,18 @@ export default {
collectionCandidateRows() { return this.selectedProducts.filter(row => this.isCollectionCandidate(row)) },
collectionCandidates() { return [...new Set(this.collectionCandidateRows.map(row => this.purchaseReady(row).pddProductId))] },
imageSearchRows() { return this.selectedProducts.filter(row => row.shopeeProductId && row.imageUrl) },
// 服务端按去重后的蝦皮商品数创建任务(见 image_search.go 的 imageSearchMaxBatchTasks),
// 这里用相同口径预估,避免提交后才发现超限。
imageSearchMaxBatchTasks() { return IMAGE_SEARCH_MAX_BATCH_TASKS },
imageSearchBatchTaskCount() { return new Set(this.imageSearchBatch.rows.map(row => row.shopeeProductId)).size },
imageSearchBatchOverLimit() { return this.imageSearchBatchTaskCount > IMAGE_SEARCH_MAX_BATCH_TASKS },
imageSearchBatchDurationText() {
const count = this.imageSearchBatchTaskCount
if (!count) return ''
const minMinutes = Math.ceil(count * 20 / 60)
const maxMinutes = Math.ceil(count * 40 / 60)
return minMinutes === maxMinutes ? `约 ${minMinutes} 分钟` : `约 ${minMinutes}~${maxMinutes} 分钟`
},
firstCreatedTaskId() { return this.purchaseResult.items.find(item => item.taskId)?.taskId || null },
formattedRaw() {
if (!this.detail.item) return ''
@@ -478,7 +493,7 @@ export default {
},
resetImageSearchBatch() { this.imageSearchBatch = this.emptyImageSearchBatch() },
async submitImageSearchBatch() {
if (this.imageSearchBatch.saving || !this.imageSearchBatch.ruleId) return
if (this.imageSearchBatch.saving || !this.imageSearchBatch.ruleId || this.imageSearchBatchOverLimit) return
this.imageSearchBatch.saving = true
try {
if (this.imageSearchBatch.deviceId) rememberPurchaseDevice(this.$store.getters.userId, this.imageSearchBatch.deviceId)