diff --git a/server/app/goauto/purchase/admin_order_filter_test.go b/server/app/goauto/purchase/admin_order_filter_test.go
new file mode 100644
index 0000000..fce0903
--- /dev/null
+++ b/server/app/goauto/purchase/admin_order_filter_test.go
@@ -0,0 +1,153 @@
+package purchase
+
+import (
+ "context"
+ "encoding/json"
+ "fmt"
+ "net/http"
+ "net/http/httptest"
+ "net/url"
+ "reflect"
+ "strings"
+ "testing"
+
+ "github.com/gin-gonic/gin"
+ jwt "github.com/go-admin-team/go-admin-core/sdk/pkg/jwtauth"
+ "github.com/google/uuid"
+ "go-admin/app/goauto/models"
+ "go-admin/app/goauto/purchasecontract"
+)
+
+func TestNormalizeAdminOrderNos(t *testing.T) {
+ hundred := make([]string, 100)
+ for i := range hundred {
+ hundred[i] = fmt.Sprintf("DEMO-%03d", i)
+ }
+ for _, tc := range []struct {
+ name, single string
+ raw, want []string
+ invalid bool
+ }{
+ {name: "absent"},
+ {name: "legacy single", single: "PARTIAL"},
+ {name: "mixed and duplicate", raw: []string{" 001\r\nDEMO-A,DEMO-B\t001", "DEMO-A,DEMO-C\u0085\uFEFF"}, want: []string{"001", "DEMO-A", "DEMO-B", "DEMO-C"}},
+ {name: "100", raw: hundred, want: hundred},
+ {name: "duplicates do not count", raw: append(append([]string{}, hundred...), hundred...), want: hundred},
+ {name: "101", raw: append(append([]string{}, hundred...), "EXTRA"), invalid: true},
+ {name: "64 runes", raw: []string{strings.Repeat("中", 64)}, want: []string{strings.Repeat("中", 64)}},
+ {name: "65 runes", raw: []string{"GOOD", strings.Repeat("中", 65)}, invalid: true},
+ {name: "single too long", single: strings.Repeat("A", 65), invalid: true},
+ {name: "empty collection", raw: []string{}, invalid: true},
+ {name: "blank collection", raw: []string{" ,,\r\n\t"}, invalid: true},
+ {name: "conflicting parameters", single: "PART", raw: []string{"DEMO-A"}, invalid: true},
+ } {
+ t.Run(tc.name, func(t *testing.T) {
+ got, err := normalizeAdminOrderNos(tc.single, tc.raw)
+ if tc.invalid {
+ if code(err) != CodeInvalidRequest {
+ t.Fatalf("expected invalid request, got %v", err)
+ }
+ return
+ }
+ if err != nil || !reflect.DeepEqual(got, tc.want) {
+ t.Fatalf("got %v, %v; want %v", got, err, tc.want)
+ }
+ })
+ }
+}
+
+func TestAdminOrderNosExactFilterAndPagination(t *testing.T) {
+ db := testDB(t)
+ f := seed(t, db, []string{purchasecontract.CapabilityPurchaseRehearsalV1}, true)
+ s := testService(db)
+ base := createAdminQueryTask(t, s, f)
+ // Isolated fixture records only; no device execution or external order creation.
+ for i, orderNo := range []string{"001", "DEMO-A", "DEMO-A", "XDEMO-A", "DEMO-A2", "DEMO-B", "' OR 1=1 --"} {
+ row := base
+ row.ID = 0
+ row.CreateRequestID = uuid.NewString()
+ row.ShopeeOrderNoSnapshot = orderNo
+ row.ShopeeTitleSnapshot = "DEMO-A"
+ row.Status = models.PurchaseTaskStatusFailed
+ if i == 5 {
+ row.Status = models.PurchaseTaskStatusCancelled
+ }
+ if err := db.Create(&row).Error; err != nil {
+ t.Fatal(err)
+ }
+ }
+ req := AdminListRequest{Page: 1, PageSize: 2, Status: models.PurchaseTaskStatusFailed, ShopeeOrderNos: []string{"001", "DEMO-A", "DEMO-B"}}
+ first, err := s.AdminList(context.Background(), req)
+ if err != nil || first.Total != 3 || len(first.Items) != 2 {
+ t.Fatalf("first: %+v %v", first, err)
+ }
+ req.Page = 2
+ second, err := s.AdminList(context.Background(), req)
+ if err != nil || second.Total != 3 || len(second.Items) != 1 {
+ t.Fatalf("second: %+v %v", second, err)
+ }
+ seen := map[uint64]bool{}
+ for _, row := range append(first.Items, second.Items...) {
+ if seen[row.ID] || (row.ShopeeOrderNoSnapshot != "001" && row.ShopeeOrderNoSnapshot != "DEMO-A") {
+ t.Fatalf("unexpected row %+v", row)
+ }
+ seen[row.ID] = true
+ }
+ for _, query := range [][]string{{"MISSING", "DEMO"}, {"%", "_"}, {"' OR 1=1 --", "MISSING"}} {
+ out, err := s.AdminList(context.Background(), AdminListRequest{ShopeeOrderNos: query})
+ if err != nil || out.Total != 0 {
+ t.Fatalf("unexpected broadened query %v: %+v %v", query, out, err)
+ }
+ }
+ // A repeated-key collection remains exact even with one item; old scalar stays LIKE.
+ out, err := s.AdminList(context.Background(), AdminListRequest{ShopeeOrderNos: []string{"DEMO-A"}})
+ if err != nil || out.Total != 2 {
+ t.Fatalf("one-item collection: %+v %v", out, err)
+ }
+ out, err = s.AdminList(context.Background(), AdminListRequest{ShopeeOrderNo: "DEMO-A"})
+ if err != nil || out.Total != 4 {
+ t.Fatalf("legacy LIKE: %+v %v", out, err)
+ }
+}
+
+func TestAdminOrderNosHandler(t *testing.T) {
+ gin.SetMode(gin.TestMode)
+ db := testDB(t)
+ f := seed(t, db, []string{purchasecontract.CapabilityPurchaseRehearsalV1}, true)
+ s := testService(db)
+ row := createAdminQueryTask(t, s, f)
+ if err := db.Model(&row).Update("shopee_order_no_snapshot", "DEMO-A").Error; err != nil {
+ t.Fatal(err)
+ }
+ for _, tc := range []struct {
+ query string
+ invalid bool
+ }{
+ {"shopeeOrderNos=DEMO-A&shopeeOrderNos=DEMO-B", false},
+ {"shopeeOrderNos=DEMO-A%2CDEMO-B", false},
+ {"shopeeOrderNo=DEMO", false},
+ {"shopeeOrderNos=", true},
+ {"shopeeOrderNos=DEMO-A&shopeeOrderNo=DEMO", true},
+ {"shopeeOrderNos=" + url.QueryEscape(strings.Repeat("A", 65)), true},
+ } {
+ for _, role := range []string{"admin", "purchaser", "after_sales"} {
+ w := httptest.NewRecorder()
+ c, _ := gin.CreateTestContext(w)
+ c.Set("JWT_PAYLOAD", jwt.MapClaims{"rolekey": role})
+ c.Request = httptest.NewRequest(http.MethodGet, "/api/admin/v1/purchase-tasks?"+tc.query, nil)
+ (Handler{DB: db}).AdminList(c)
+ if tc.invalid {
+ if !strings.Contains(w.Body.String(), CodeInvalidRequest) {
+ t.Fatalf("invalid query accepted: %s", w.Body.String())
+ }
+ continue
+ }
+ var result struct {
+ Data AdminListResponse `json:"data"`
+ }
+ if err := json.Unmarshal(w.Body.Bytes(), &result); err != nil || w.Code != http.StatusOK || result.Data.Total != 1 {
+ t.Fatalf("query %s: %d %s", tc.query, w.Code, w.Body.String())
+ }
+ }
+ }
+}
diff --git a/server/app/goauto/purchase/admin_query.go b/server/app/goauto/purchase/admin_query.go
index 444d414..cf2af1f 100644
--- a/server/app/goauto/purchase/admin_query.go
+++ b/server/app/goauto/purchase/admin_query.go
@@ -6,6 +6,8 @@ import (
"errors"
"strings"
"time"
+ "unicode"
+ "unicode/utf8"
"go-admin/app/goauto/models"
@@ -13,15 +15,16 @@ import (
)
type AdminListRequest struct {
- Page int
- PageSize int
- TaskID uint64
- Status string
- ExecutionMode string
- TaskType string
- SYBProductID uint64
- ShopeeOrderNo string
- PDDOrderNo string
+ Page int
+ PageSize int
+ TaskID uint64
+ Status string
+ ExecutionMode string
+ TaskType string
+ SYBProductID uint64
+ ShopeeOrderNo string
+ ShopeeOrderNos []string
+ PDDOrderNo string
}
type AdminTaskItem struct {
@@ -112,7 +115,48 @@ type AdminDetailResponse struct {
// Query-only value: never a PurchaseTask status or a logistics writeback state.
const adminStatusSYBWritebackSucceeded = "syb_writeback_succeeded"
+// A nil list means the optional filter was absent; an explicitly empty list
+// must not silently turn a restricted lookup into an unfiltered query.
+func normalizeAdminOrderNos(single string, raw []string) ([]string, error) {
+ single = strings.TrimSpace(single)
+ if utf8.RuneCountInString(single) > 64 {
+ return nil, fail(CodeInvalidRequest, "每个蝦皮订单号最多 64 个字符")
+ }
+ if raw == nil {
+ return nil, nil
+ }
+ if single != "" {
+ return nil, fail(CodeInvalidRequest, "蝦皮订单号单值与批量参数不能同时使用")
+ }
+ values := make([]string, 0, len(raw))
+ seen := make(map[string]bool)
+ for _, entry := range raw {
+ for _, value := range strings.FieldsFunc(entry, func(r rune) bool {
+ return r == ',' || r == ',' || unicode.IsSpace(r) || r == '\uFEFF'
+ }) {
+ if utf8.RuneCountInString(value) > 64 {
+ return nil, fail(CodeInvalidRequest, "每个蝦皮订单号最多 64 个字符")
+ }
+ if !seen[value] {
+ seen[value] = true
+ values = append(values, value)
+ if len(values) > 100 {
+ return nil, fail(CodeInvalidRequest, "最多查询 100 个蝦皮订单号,请分批查询")
+ }
+ }
+ }
+ }
+ if len(values) == 0 {
+ return nil, fail(CodeInvalidRequest, "批量蝦皮订单号不能为空")
+ }
+ return values, nil
+}
+
func (s *Service) AdminList(ctx context.Context, req AdminListRequest) (AdminListResponse, error) {
+ orderNos, err := normalizeAdminOrderNos(req.ShopeeOrderNo, req.ShopeeOrderNos)
+ if err != nil {
+ return AdminListResponse{}, err
+ }
if req.Page < 1 {
req.Page = 1
}
@@ -152,6 +196,9 @@ func (s *Service) AdminList(ctx context.Context, req AdminListRequest) (AdminLis
if orderNo := strings.TrimSpace(req.ShopeeOrderNo); orderNo != "" {
query = query.Where("shopee_order_no_snapshot LIKE ?", "%"+orderNo+"%")
}
+ if len(orderNos) > 0 {
+ query = query.Where("shopee_order_no_snapshot IN ?", orderNos)
+ }
if orderNo := strings.TrimSpace(req.PDDOrderNo); orderNo != "" {
query = query.Where("pdd_order_no LIKE ?", "%"+orderNo+"%")
}
diff --git a/server/app/goauto/purchase/handler.go b/server/app/goauto/purchase/handler.go
index 51aa077..d10b0eb 100644
--- a/server/app/goauto/purchase/handler.go
+++ b/server/app/goauto/purchase/handler.go
@@ -52,9 +52,10 @@ func (h Handler) AdminList(c *gin.Context) {
response, err := service.AdminList(c.Request.Context(), AdminListRequest{
Page: page, PageSize: pageSize, TaskID: taskID, SYBProductID: sybProductID,
Status: strings.TrimSpace(c.Query("status")), ExecutionMode: strings.TrimSpace(c.Query("executionMode")),
- TaskType: strings.TrimSpace(c.Query("taskType")),
- ShopeeOrderNo: strings.TrimSpace(c.Query("shopeeOrderNo")),
- PDDOrderNo: strings.TrimSpace(c.Query("pddOrderNo")),
+ TaskType: strings.TrimSpace(c.Query("taskType")),
+ ShopeeOrderNo: strings.TrimSpace(c.Query("shopeeOrderNo")),
+ ShopeeOrderNos: c.Request.URL.Query()["shopeeOrderNos"],
+ PDDOrderNo: strings.TrimSpace(c.Query("pddOrderNo")),
})
if err != nil {
writeError(c, err)
diff --git a/web/src/api/goauto/purchase-tasks.js b/web/src/api/goauto/purchase-tasks.js
index dcd91d8..6bc5fc3 100644
--- a/web/src/api/goauto/purchase-tasks.js
+++ b/web/src/api/goauto/purchase-tasks.js
@@ -1,6 +1,24 @@
import request from '@/utils/request'
-export function listPurchaseTasks(params) { return request({ url: '/api/admin/v1/purchase-tasks', method: 'get', params }) }
+export function listPurchaseTasks(params) {
+ return request({
+ url: '/api/admin/v1/purchase-tasks', method: 'get', params,
+ paramsSerializer: {
+ serialize(values) {
+ const query = new URLSearchParams()
+ Object.entries(values).forEach(([key, value]) => {
+ if (value == null) return
+ if (key === 'shopeeOrderNos' && Array.isArray(value)) {
+ // Preserve an explicitly empty list so the server rejects it.
+ if (!value.length) query.append(key, '')
+ value.forEach(item => query.append(key, item))
+ } else query.append(key, value)
+ })
+ return query.toString()
+ }
+ }
+ })
+}
export function getPurchaseTask(taskId) { return request({ url: `/api/admin/v1/purchase-tasks/${taskId}`, method: 'get' }) }
export function previewPurchaseTasks(data, options = {}) { return request({ url: '/api/admin/v1/purchase-tasks/batch-preview', method: 'post', data, ...options }) }
export function matchPurchaseSpecsBatch(data) { return request({ url: '/api/admin/v1/purchase-tasks/batch-spec-match', method: 'post', data, timeout: 610000 }) }
diff --git a/web/src/utils/purchase-order-filter.js b/web/src/utils/purchase-order-filter.js
new file mode 100644
index 0000000..50aaf4a
--- /dev/null
+++ b/web/src/utils/purchase-order-filter.js
@@ -0,0 +1,10 @@
+// Match the Server's whitespace/comma splitting; keep identifiers as strings.
+export function parsePurchaseOrderFilter(raw = '') {
+ const values = [...new Set(raw.split(/[,,\s\u0085]+/u).filter(Boolean))]
+ const error = values.length > 100
+ ? '最多查询 100 个蝦皮订单号,请分批查询'
+ : values.some(value => [...value].length > 64)
+ ? '每个蝦皮订单号最多 64 个字符'
+ : ''
+ return { values, error }
+}
diff --git a/web/src/views/goauto/purchase-tasks/index.vue b/web/src/views/goauto/purchase-tasks/index.vue
index 41088b8..f8a9a4c 100644
--- a/web/src/views/goauto/purchase-tasks/index.vue
+++ b/web/src/views/goauto/purchase-tasks/index.vue
@@ -6,11 +6,17 @@
单个支持包含查询,多个按完整订单号匹配。