diff --git a/server/app/goauto/returnmatch/handler.go b/server/app/goauto/returnmatch/handler.go
index eb16380..4f950b8 100644
--- a/server/app/goauto/returnmatch/handler.go
+++ b/server/app/goauto/returnmatch/handler.go
@@ -77,6 +77,10 @@ func (h Handler) BatchMatch(c *gin.Context) {
}
_, operator := operatorFromContext(c)
resp, err := NewService(db).BatchMatch(c.Request.Context(), BatchMatchRequest{SYBProductIDs: body.SYBProductIDs, Operator: operator})
+ if errors.Is(err, ErrTooManyItems) {
+ c.JSON(http.StatusBadRequest, gin.H{"code": "INVALID_REQUEST", "message": ErrTooManyItems.Error()})
+ return
+ }
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"code": "INTERNAL", "message": "服务端处理失败"})
return
diff --git a/server/app/goauto/returnmatch/service.go b/server/app/goauto/returnmatch/service.go
index f20adec..2b3f26c 100644
--- a/server/app/goauto/returnmatch/service.go
+++ b/server/app/goauto/returnmatch/service.go
@@ -68,10 +68,23 @@ const (
ReasonConflict = "conflict"
)
+// maxBatchMatchItems caps a single「匹配退货」submission (#339: SYB list page
+// size can now go up to 500/page, so the button's selection can exceed the
+// previously-unbounded batch-match request size).
+const maxBatchMatchItems = 500
+
+// ErrTooManyItems is returned by BatchMatch when the caller submits more than
+// maxBatchMatchItems SYB product ids; the handler turns this into an
+// INVALID_REQUEST response instead of a 500.
+var ErrTooManyItems = errors.New("sybProductIds 一次最多 500 条")
+
// BatchMatch implements issue #338's manual "匹配退货" trigger. It is only
// ever called from the batch-match button (ticked rows) — no scheduler, no
// yeeke-sync/SYB-import hook calls this (rule: 手动触发, 无定时任务).
func (s *Service) BatchMatch(ctx context.Context, req BatchMatchRequest) (BatchMatchResponse, error) {
+ if len(req.SYBProductIDs) > maxBatchMatchItems {
+ return BatchMatchResponse{}, ErrTooManyItems
+ }
resp, err := s.batchMatch(ctx, req)
if len(req.SYBProductIDs) > 0 {
// The batch record is written after the per-row transactions have
diff --git a/server/app/goauto/returnmatch/service_test.go b/server/app/goauto/returnmatch/service_test.go
index 00de4ca..0de5464 100644
--- a/server/app/goauto/returnmatch/service_test.go
+++ b/server/app/goauto/returnmatch/service_test.go
@@ -111,6 +111,31 @@ func TestBatchMatch_EndToEnd(t *testing.T) {
}
}
+func TestBatchMatch_RejectsMoreThan500Items(t *testing.T) {
+ db := testDB(t)
+ s := NewService(db)
+
+ ids := make([]uint64, maxBatchMatchItems+1)
+ for i := range ids {
+ ids[i] = uint64(i + 1)
+ }
+ resp, err := s.BatchMatch(context.Background(), BatchMatchRequest{SYBProductIDs: ids, Operator: "tester"})
+ if !errors.Is(err, ErrTooManyItems) {
+ t.Fatalf("expected ErrTooManyItems, got %v", err)
+ }
+ if len(resp.Items) != 0 {
+ t.Fatalf("expected empty response on rejection, got %+v", resp)
+ }
+
+ var batchCount int64
+ if err := db.Model(&models.ReturnMatchBatch{}).Count(&batchCount).Error; err != nil {
+ t.Fatal(err)
+ }
+ if batchCount != 0 {
+ t.Fatalf("rejected oversized batch must not be recorded, got %d rows", batchCount)
+ }
+}
+
func TestBatchMatch_ExpiredDeadlineNotMatched(t *testing.T) {
db := testDB(t)
s := NewService(db)
diff --git a/server/app/goauto/sybimport/service.go b/server/app/goauto/sybimport/service.go
index 2c9a1ff..480158c 100644
--- a/server/app/goauto/sybimport/service.go
+++ b/server/app/goauto/sybimport/service.go
@@ -65,8 +65,8 @@ func (service *Service) List(ctx context.Context, request ListRequest) (ListResp
if request.PageSize < 1 {
request.PageSize = 20
}
- if request.PageSize > 100 {
- request.PageSize = 100
+ if request.PageSize > 500 {
+ request.PageSize = 500
}
query := service.DB.WithContext(ctx).Model(&models.SYBProduct{})
if request.ShopName = strings.TrimSpace(request.ShopName); request.ShopName != "" {
diff --git a/server/app/goauto/sybimport/service_test.go b/server/app/goauto/sybimport/service_test.go
index 2eede97..ca7c6e9 100644
--- a/server/app/goauto/sybimport/service_test.go
+++ b/server/app/goauto/sybimport/service_test.go
@@ -93,6 +93,36 @@ func TestServiceListRejectsTooManyOrTooLongOrderCodes(t *testing.T) {
}
}
+func TestServiceListCapsPageSizeAt500(t *testing.T) {
+ db := openTestDB(t)
+ order := realOrder()
+ for i := 0; i < 3; i++ {
+ detail := realDetailA()
+ detail.ID += uint64(i)
+ order.Code = fmt.Sprintf("260728TB95MJTQ-%d", i)
+ if _, err := sybimport.ApplyDetail(context.Background(), db, order, detail); err != nil {
+ t.Fatalf("apply detail %d: %v", i, err)
+ }
+ }
+ service := sybimport.NewService(db)
+
+ overLimit, err := service.List(context.Background(), sybimport.ListRequest{Page: 1, PageSize: 600})
+ if err != nil {
+ t.Fatalf("list with oversized page size: %v", err)
+ }
+ if overLimit.PageSize != 500 {
+ t.Fatalf("expected page size capped at 500, got %d", overLimit.PageSize)
+ }
+
+ within, err := service.List(context.Background(), sybimport.ListRequest{Page: 1, PageSize: 500})
+ if err != nil {
+ t.Fatalf("list at exactly 500: %v", err)
+ }
+ if within.PageSize != 500 {
+ t.Fatalf("expected page size of exactly 500 to pass through unchanged, got %d", within.PageSize)
+ }
+}
+
func TestServiceListRejectsInvalidParseStatus(t *testing.T) {
db := openTestDB(t)
service := sybimport.NewService(db)
diff --git a/web/src/views/goauto/syb-products/index.vue b/web/src/views/goauto/syb-products/index.vue
index 4230ca5..f7a1d68 100644
--- a/web/src/views/goauto/syb-products/index.vue
+++ b/web/src/views/goauto/syb-products/index.vue
@@ -12,7 +12,7 @@
- 查询重置AI 匹配{{ aiMatchCandidates.length }}图搜采集{{ imageSearchRows.length }}创建采集{{ collectionCandidates.length }}创建采购{{ purchaseCandidates.length }}匹配退货{{ returnMatchCandidateIds.length }}
+ 查询重置AI 匹配{{ aiMatchCandidates.length }}图搜采集{{ imageSearchRows.length }}创建采集{{ collectionCandidates.length }}创建采购{{ purchaseCandidates.length }}匹配退货{{ returnMatchCandidateIds.length }}
@@ -75,7 +75,7 @@
详情采购
-
+
@@ -333,6 +333,12 @@ import PddProductDetailDrawer from '../pdd-products/PddProductDetailDrawer.vue'
// 与服务端 image_search.go 的 imageSearchMaxBatchTasks 保持一致(单批去重后任务数上限,#280)。
const IMAGE_SEARCH_MAX_BATCH_TASKS = 50
+// #339: 各批量按钮的服务端上限,用于勾选数超限时禁用按钮并提示原因。
+// 创建采购 / AI 匹配:purchase/batch.go maxBatchPurchaseItems;创建采集(去重后 PDD 商品数):
+// task/admin_service.go BatchCreate 的 100 上限;匹配退货:returnmatch/service.go maxBatchMatchItems。
+const PURCHASE_BATCH_MAX = 100
+const COLLECTION_BATCH_MAX = 100
+const RETURN_MATCH_BATCH_MAX = 500
export default {
name: 'GoAutoSybProducts',
@@ -353,7 +359,7 @@ export default {
imageSearchBatch: this.emptyImageSearchBatch(),
collectionBatchData: { ruleId: null, deviceId: null },
collectionBatchRules: { ruleId: [{ required: true, message: '请选择采集规则', trigger: 'change' }] },
- query: { page: 1, pageSize: 20, shopName: '', orderCodesText: '', parseStatus: '', processStage: '' },
+ query: { page: 1, pageSize: 100, shopName: '', orderCodesText: '', parseStatus: '', processStage: '' },
returnMatchByProductId: {},
returnMatchLoading: false,
returnMatchBatchLoading: false,
@@ -372,20 +378,34 @@ export default {
processStageOptions() { return [{ value: 'manual_action', label: '待人工处理' }, { value: 'pdd_unlinked', label: '未关联 PDD' }, { value: 'pdd_pending', label: 'PDD 待采集' }, { value: 'pdd_collecting', label: 'PDD 采集中' }, { value: 'pdd_collection_failed', label: 'PDD 采集失败' }, { value: 'color_mapping', label: '规格待匹配' }, { value: 'purchase_ready', label: '可创建采购' }, { value: 'task_created', label: '已创建任务' }, { value: 'purchase_succeeded', label: '采购成功' }, { value: 'order_review', label: '待人工核对' }, { value: 'return_pending', label: '退货待确认' }, { value: 'return_used', label: '已用退货' }] },
returnMatchCandidateIds() { return this.selectedProducts.filter(row => this.isReturnMatchCandidate(row)).map(row => row.id) },
aiMatchCandidates() { return this.selectedProducts.filter(row => this.purchaseReady(row).aiMatchEligible === true) },
+ aiMatchButtonOverLimit() { return this.aiMatchCandidates.length > PURCHASE_BATCH_MAX },
aiMatchButtonReason() {
if (this.purchaseReadinessLoading) return '正在检查 AI 匹配资格'
if (!this.selectedProducts.length) return '请先勾选当前页中可处理的明细'
+ if (this.aiMatchButtonOverLimit) return `一次最多 ${PURCHASE_BATCH_MAX} 条`
if (this.aiMatchCandidates.length) return ''
const reasons = [...new Set(this.selectedProducts.map(row => this.purchaseReady(row).aiMatchDisabledReason).filter(Boolean))]
return reasons[0] || '所选明细不满足 AI 匹配前提'
},
purchaseCandidates() { return this.selectedProducts.filter(row => this.isPurchaseCandidate(row)) },
+ // #339: 创建采购上限保持 100(服务端 maxBatchPurchaseItems 不放宽),超出勾选数时禁用并提示。
+ purchaseButtonOverLimit() { return this.purchaseCandidates.length > PURCHASE_BATCH_MAX },
+ purchaseButtonReason() { return this.purchaseButtonOverLimit ? `一次最多 ${PURCHASE_BATCH_MAX} 条` : '' },
collectionCandidateRows() { return this.selectedProducts.filter(row => this.isCollectionCandidate(row)) },
collectionCandidates() { return [...new Set(this.collectionCandidateRows.map(row => this.purchaseReady(row).pddProductId))] },
+ // #339: 创建采集按去重后的 PDD 商品数计数,与服务端 task.BatchCreate 的 100 上限口径一致。
+ collectionButtonOverLimit() { return this.collectionCandidates.length > COLLECTION_BATCH_MAX },
+ collectionButtonReason() { return this.collectionButtonOverLimit ? `一次最多 ${COLLECTION_BATCH_MAX} 个 PDD 商品` : '' },
imageSearchRows() { return this.selectedProducts.filter(row => row.shopeeProductId && row.imageUrl) },
// 服务端按去重后的蝦皮商品数创建任务(见 image_search.go 的 imageSearchMaxBatchTasks),
// 这里用相同口径预估,避免提交后才发现超限。
imageSearchMaxBatchTasks() { return IMAGE_SEARCH_MAX_BATCH_TASKS },
+ imageSearchButtonTaskCount() { return new Set(this.imageSearchRows.map(row => row.shopeeProductId)).size },
+ imageSearchButtonOverLimit() { return this.imageSearchButtonTaskCount > IMAGE_SEARCH_MAX_BATCH_TASKS },
+ imageSearchButtonReason() { return this.imageSearchButtonOverLimit ? `一次最多 ${IMAGE_SEARCH_MAX_BATCH_TASKS} 个蝦皮商品` : '' },
+ // #339: 匹配退货服务端上限(returnmatch.maxBatchMatchItems),超出直接勾选数即禁用。
+ returnMatchButtonOverLimit() { return this.returnMatchCandidateIds.length > RETURN_MATCH_BATCH_MAX },
+ returnMatchButtonReason() { return this.returnMatchButtonOverLimit ? `一次最多 ${RETURN_MATCH_BATCH_MAX} 条` : '' },
imageSearchBatchTaskCount() { return new Set(this.imageSearchBatch.rows.map(row => row.shopeeProductId)).size },
imageSearchBatchOverLimit() { return this.imageSearchBatchTaskCount > IMAGE_SEARCH_MAX_BATCH_TASKS },
imageSearchBatchDurationText() {
@@ -432,7 +452,7 @@ export default {
}
},
search() { this.query.page = 1; this.load() },
- reset() { this.query = { page: 1, pageSize: 20, shopName: '', orderCodesText: '', parseStatus: '', processStage: '' }; this.load() },
+ reset() { this.query = { page: 1, pageSize: 100, shopName: '', orderCodesText: '', parseStatus: '', processStage: '' }; this.load() },
normalizeShopName(value) { return String(value || '').normalize('NFKC').trim().toLocaleLowerCase() },
async ensureShopOptions() {
if (this.shopOptionsLoaded) return
@@ -477,16 +497,29 @@ export default {
purchasePriceText(item) { if (item.minUnitPriceCent === undefined || item.maxUnitPriceCent === undefined) return ''; return `允许单价 ¥${(item.minUnitPriceCent / 100).toFixed(2)}~¥${(item.maxUnitPriceCent / 100).toFixed(2)}` },
processMeta(stage) { return { manual_action: { label: '待人工处理', type: 'warning' }, pdd_unlinked: { label: '未关联 PDD', type: 'info' }, pdd_pending: { label: 'PDD 待采集', type: 'info' }, pdd_collecting: { label: 'PDD 采集中', type: 'primary' }, pdd_collection_failed: { label: 'PDD 采集失败', type: 'danger' }, color_mapping: { label: '规格待匹配', type: 'warning' }, purchase_ready: { label: '可创建采购', type: 'success' }, task_created: { label: '已创建任务', type: 'primary' }, purchase_succeeded: { label: '采购成功', type: 'success' }, order_review: { label: '待人工核对', type: 'danger' }, return_pending: { label: '退货待确认', type: 'warning' }, return_used: { label: '已用退货', type: 'info' }}[stage] || { label: '待人工处理', type: 'warning' } },
purchaseActionLabel(item) { return { open_pdd_link: '去关联', open_mapping: '去匹配', open_shopee: '查看蝦皮商品', open_pdd: '查看 PDD 商品', open_task: '查看任务', reparse: '查看并处理', select_device: '重新选择设备', refresh: '刷新' }[item.nextAction] || '' },
+ // #339: the page can now show up to 500 rows, but the preview endpoint
+ // (previewPurchaseTasks -> purchase/batch.go maxBatchPurchaseItems) still
+ // caps a single request at 100 ids, so this splits the current page into
+ // sequential chunks of <=100 and merges their results. Any chunk failing
+ // falls back to the same page-wide failure state the single-request path
+ // used before, so rows never end up in a partially-updated mix of real
+ // and failed readiness.
async loadPurchaseReadiness(ids, requestOptions = {}, generation = this.loadGeneration, selectedIDsOverride = null) {
if (generation !== this.loadGeneration) return
const selectedIDs = selectedIDsOverride || new Set(this.selectedProducts.map(row => row.id))
this.purchaseReadiness = {}
if (!this.canPurchase || !ids.length) { this.purchaseReadinessLoading = false; return }
this.purchaseReadinessLoading = true
+ const chunkSize = 100
+ const merged = {}
try {
- const r = await previewPurchaseTasks({ sybProductIds: ids }, { ...requestOptions, suppressErrorMessage: true })
- if (generation !== this.loadGeneration) return
- this.purchaseReadiness = Object.fromEntries(r.data.items.map(item => [item.sybProductId, item]))
+ for (let start = 0; start < ids.length; start += chunkSize) {
+ const chunk = ids.slice(start, start + chunkSize)
+ const r = await previewPurchaseTasks({ sybProductIds: chunk }, { ...requestOptions, suppressErrorMessage: true })
+ if (generation !== this.loadGeneration) return
+ for (const item of r.data.items) merged[item.sybProductId] = item
+ }
+ this.purchaseReadiness = merged
await this.$nextTick()
const valid = this.products.filter(row => selectedIDs.has(row.id) && this.isSelectableCandidate(row))
this.$refs.productTable?.clearSelection()