diff --git a/server/app/goauto/task/image_search.go b/server/app/goauto/task/image_search.go
index 1fa6e34..02e4ed2 100644
--- a/server/app/goauto/task/image_search.go
+++ b/server/app/goauto/task/image_search.go
@@ -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 == "" {
diff --git a/server/app/goauto/task/image_search_batch_limit_test.go b/server/app/goauto/task/image_search_batch_limit_test.go
new file mode 100644
index 0000000..e7e7bba
--- /dev/null
+++ b/server/app/goauto/task/image_search_batch_limit_test.go
@@ -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)
+ }
+}
diff --git a/web/src/views/goauto/syb-products/index.vue b/web/src/views/goauto/syb-products/index.vue
index 02b7da7..4d5e1ec 100644
--- a/web/src/views/goauto/syb-products/index.vue
+++ b/web/src/views/goauto/syb-products/index.vue
@@ -117,9 +117,9 @@
- 覆盖已有关联{{ row.imageUrl }}
+ 覆盖已有关联{{ row.imageUrl }}
{{ row.success ? '已创建' : row.code === 'IMAGE_SEARCH_ALREADY_LINKED' ? '已跳过' : '失败' }}{{ row.success ? `任务 #${row.taskId}` : row.message }}
- {{ imageSearchBatch.step === 'confirm' ? '取消' : '关闭' }}创建图搜任务
+ {{ imageSearchBatch.step === 'confirm' ? '取消' : '关闭' }}创建图搜任务
@@ -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)