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/handler.go b/server/app/goauto/sybimport/handler.go
index 7d0b823..2a8f417 100644
--- a/server/app/goauto/sybimport/handler.go
+++ b/server/app/goauto/sybimport/handler.go
@@ -47,7 +47,7 @@ func (handler Handler) List(c *gin.Context) {
return
}
response, err := service.List(c.Request.Context(), ListRequest{
- Page: page, PageSize: pageSize, ShopName: c.Query("shopName"), OrderCodes: []string{c.Query("orderCodes")}, ParseStatus: strings.TrimSpace(c.Query("parseStatus")), ProcessStage: strings.TrimSpace(c.Query("processStage")),
+ Page: page, PageSize: pageSize, ShopName: c.Query("shopName"), OrderCodes: []string{c.Query("orderCodes")}, ParseStatus: strings.TrimSpace(c.Query("parseStatus")), ProcessStage: strings.TrimSpace(c.Query("processStage")), CreatedFrom: strings.TrimSpace(c.Query("createdFrom")), CreatedTo: strings.TrimSpace(c.Query("createdTo")),
})
if err != nil {
writeError(c, err)
diff --git a/server/app/goauto/sybimport/service.go b/server/app/goauto/sybimport/service.go
index 2c9a1ff..3fe2e00 100644
--- a/server/app/goauto/sybimport/service.go
+++ b/server/app/goauto/sybimport/service.go
@@ -5,6 +5,7 @@ import (
"errors"
"fmt"
"strings"
+ "time"
"go-admin/app/goauto/models"
"go-admin/app/goauto/purchase"
@@ -44,6 +45,8 @@ type ListRequest struct {
OrderCodes []string
ParseStatus string
ProcessStage string
+ CreatedFrom string
+ CreatedTo string
}
type ListResponse struct {
@@ -65,10 +68,20 @@ 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{})
+ createdFrom, createdTo, err := createdAtRange(request.CreatedFrom, request.CreatedTo)
+ if err != nil {
+ return ListResponse{}, err
+ }
+ if createdFrom != nil {
+ query = query.Where("created_at >= ?", *createdFrom)
+ }
+ if createdTo != nil {
+ query = query.Where("created_at < ?", *createdTo)
+ }
if request.ShopName = strings.TrimSpace(request.ShopName); request.ShopName != "" {
if len([]rune(request.ShopName)) > 255 {
return ListResponse{}, invalidRequest("店铺名称不能超过 255 个字符")
@@ -142,6 +155,43 @@ func (service *Service) List(ctx context.Context, request ListRequest) (ListResp
return ListResponse{Items: items, Total: total, Page: request.Page, PageSize: request.PageSize}, nil
}
+// createdAtRange turns inclusive YYYY-MM-DD bounds into a half-open time
+// range. The bounds are interpreted in the server's local timezone, matching
+// the timestamps written by GORM for this service.
+func createdAtRange(from, to string) (*time.Time, *time.Time, error) {
+ from = strings.TrimSpace(from)
+ to = strings.TrimSpace(to)
+ if from == "" && to == "" {
+ return nil, nil, nil
+ }
+ parse := func(value, label string) (*time.Time, error) {
+ if value == "" {
+ return nil, nil
+ }
+ parsed, err := time.ParseInLocation("2006-01-02", value, time.Local)
+ if err != nil {
+ return nil, invalidRequest(label + " 必须是 YYYY-MM-DD")
+ }
+ return &parsed, nil
+ }
+ start, err := parse(from, "createdFrom")
+ if err != nil {
+ return nil, nil, err
+ }
+ endDay, err := parse(to, "createdTo")
+ if err != nil {
+ return nil, nil, err
+ }
+ if start != nil && endDay != nil && start.After(*endDay) {
+ return nil, nil, invalidRequest("createdFrom 不能晚于 createdTo")
+ }
+ if endDay != nil {
+ end := endDay.AddDate(0, 0, 1)
+ endDay = &end
+ }
+ return start, endDay, nil
+}
+
func normalizeOrderCodes(raw []string) ([]string, error) {
seen := make(map[string]bool, len(raw))
result := make([]string, 0, len(raw))
diff --git a/server/app/goauto/sybimport/service_test.go b/server/app/goauto/sybimport/service_test.go
index 2eede97..e104afc 100644
--- a/server/app/goauto/sybimport/service_test.go
+++ b/server/app/goauto/sybimport/service_test.go
@@ -7,6 +7,7 @@ import (
"fmt"
"strings"
"testing"
+ "time"
"go-admin/app/goauto/models"
"go-admin/app/goauto/sybimport"
@@ -93,6 +94,76 @@ 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 TestServiceListFiltersByCreatedDateInclusive(t *testing.T) {
+ db := openTestDB(t)
+ order := realOrder()
+ for i, day := range []string{"2026-09-01", "2026-09-02", "2026-09-03"} {
+ rowOrder := order
+ rowOrder.Code = fmt.Sprintf("CREATED-%d", i)
+ rowOrder.StockID += uint64(i)
+ detail := realDetailA()
+ detail.ID += uint64(i)
+ result, err := sybimport.ApplyDetail(context.Background(), db, rowOrder, detail)
+ if err != nil {
+ t.Fatal(err)
+ }
+ created, err := time.ParseInLocation("2006-01-02", day, time.Local)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if err := db.Model(&models.SYBProduct{}).Where("id = ?", result.SYBProduct.ID).Update("created_at", created).Error; err != nil {
+ t.Fatal(err)
+ }
+ }
+ service := sybimport.NewService(db)
+
+ between, err := service.List(context.Background(), sybimport.ListRequest{CreatedFrom: "2026-09-01", CreatedTo: "2026-09-02"})
+ if err != nil || between.Total != 2 {
+ t.Fatalf("inclusive created date range should return two rows, total=%d err=%v", between.Total, err)
+ }
+ fromOnly, err := service.List(context.Background(), sybimport.ListRequest{CreatedFrom: "2026-09-03"})
+ if err != nil || fromOnly.Total != 1 {
+ t.Fatalf("created-from filter should return one row, total=%d err=%v", fromOnly.Total, err)
+ }
+ toOnly, err := service.List(context.Background(), sybimport.ListRequest{CreatedTo: "2026-09-01"})
+ if err != nil || toOnly.Total != 1 {
+ t.Fatalf("created-to filter should return one row, total=%d err=%v", toOnly.Total, err)
+ }
+ if _, err := service.List(context.Background(), sybimport.ListRequest{CreatedFrom: "2026-09-04", CreatedTo: "2026-09-01"}); serviceErrCode(t, err) != sybimport.CodeInvalidRequest {
+ t.Fatalf("reversed created date range should be rejected: %v", err)
+ }
+}
+
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..91fff83 100644
--- a/web/src/views/goauto/syb-products/index.vue
+++ b/web/src/views/goauto/syb-products/index.vue
@@ -12,7 +12,8 @@
- 查询重置AI 匹配{{ aiMatchCandidates.length }}图搜采集{{ imageSearchRows.length }}创建采集{{ collectionCandidates.length }}创建采购{{ purchaseCandidates.length }}匹配退货{{ returnMatchCandidateIds.length }}
+
+ 查询重置AI 匹配{{ aiMatchCandidates.length }}图搜采集{{ imageSearchRows.length }}创建采集{{ collectionCandidates.length }}创建采购{{ purchaseCandidates.length }}匹配退货{{ returnMatchCandidateIds.length }}
@@ -75,7 +76,7 @@
详情采购
-
+
@@ -333,6 +334,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 +360,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: 200, shopName: '', orderCodesText: '', parseStatus: '', processStage: '', createdAtRange: [] },
returnMatchByProductId: {},
returnMatchLoading: false,
returnMatchBatchLoading: false,
@@ -372,20 +379,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() {
@@ -412,7 +433,8 @@ export default {
this.$refs.productTable?.clearSelection()
const requestOptions = allowNetworkRetry ? { suppressNetworkError: true } : {}
try {
- const r = await listSybProducts({ page: this.query.page, pageSize: this.query.pageSize, shopName: this.query.shopName.trim(), orderCodes: orderCodes.join(','), parseStatus: this.query.parseStatus, processStage: this.query.processStage }, requestOptions)
+ const [createdFrom = '', createdTo = ''] = this.query.createdAtRange || []
+ const r = await listSybProducts({ page: this.query.page, pageSize: this.query.pageSize, shopName: this.query.shopName.trim(), orderCodes: orderCodes.join(','), parseStatus: this.query.parseStatus, processStage: this.query.processStage, createdFrom, createdTo }, requestOptions)
if (generation !== this.loadGeneration) return
this.products = r.data.items
this.total = r.data.total
@@ -432,7 +454,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: 200, shopName: '', orderCodesText: '', parseStatus: '', processStage: '', createdAtRange: [] }; this.load() },
normalizeShopName(value) { return String(value || '').normalize('NFKC').trim().toLocaleLowerCase() },
async ensureShopOptions() {
if (this.shopOptionsLoaded) return
@@ -477,16 +499,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()