Merge branch 'fix/b280-imagesearch' into integrate/imagesearch
This commit is contained in:
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user