From 00fb274fd4ffe62671ed8c3ae8c15b066e12223f Mon Sep 17 00:00:00 2001 From: QiuSW <105186638@qq.com> Date: Thu, 24 Sep 2026 14:35:53 +0800 Subject: [PATCH] feat(syb): support 200/500 rows per page on SYB products list (#339) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bump the syb-products page-size options to 20/50/100/200/500 with a new 100 default, cap the server-side sybimport.List page size at 500, chunk the per-page purchase-readiness preview into <=100-id requests, and add per-button selection limits (with disabled+tooltip) for AI 匹配, 创建采购, 创建采集, 图搜采集 and 匹配退货 so a larger page never silently exceeds a batch endpoint's cap. 创建采购's 100-item server cap is left untouched. Also caps returnmatch.BatchMatch at 500 ids (INVALID_REQUEST beyond that). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01NTDbDcwbDw1TSAcE6wfh2F --- server/app/goauto/returnmatch/handler.go | 4 ++ server/app/goauto/returnmatch/service.go | 13 +++++ server/app/goauto/returnmatch/service_test.go | 25 ++++++++++ server/app/goauto/sybimport/service.go | 4 +- server/app/goauto/sybimport/service_test.go | 30 ++++++++++++ web/src/views/goauto/syb-products/index.vue | 47 ++++++++++++++++--- 6 files changed, 114 insertions(+), 9 deletions(-) diff --git a/server/app/goauto/returnmatch/handler.go b/server/app/goauto/returnmatch/handler.go index 1968c7e..80263ad 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 d12a1be..8b7d23c 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 af537fa..4ffabf8 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 }} @@ -73,7 +73,7 @@ - + @@ -329,6 +329,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', @@ -349,7 +355,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, @@ -368,20 +374,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() { @@ -428,7 +448,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 @@ -473,16 +493,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()