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 @@ - + - + @@ -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)