diff --git a/backend/app/http/tenant/dto/order_admin.go b/backend/app/http/tenant/dto/order_admin.go index dddde31..7a03399 100644 --- a/backend/app/http/tenant/dto/order_admin.go +++ b/backend/app/http/tenant/dto/order_admin.go @@ -14,6 +14,9 @@ type AdminOrderListFilter struct { // Pagination 分页参数:page/limit(通用)。 requests.Pagination `json:",inline" query:",inline"` + // SortQueryFilter 排序参数:asc/desc(逗号分隔字段名);字段白名单在 service 层统一校验。 + requests.SortQueryFilter `json:",inline" query:",inline"` + // UserID 下单用户ID(可选):按买家用户ID精确过滤。 UserID *int64 `json:"user_id,omitempty" query:"user_id"` diff --git a/backend/app/services/order.go b/backend/app/services/order.go index 97f7558..07b8afe 100644 --- a/backend/app/services/order.go +++ b/backend/app/services/order.go @@ -5,6 +5,7 @@ import ( "encoding/json" "errors" "fmt" + "strings" "time" "quyun/v2/app/errorx" @@ -18,6 +19,7 @@ import ( "github.com/samber/lo" "github.com/sirupsen/logrus" "go.ipao.vip/gen" + "go.ipao.vip/gen/field" "gorm.io/gorm" "gorm.io/gorm/clause" @@ -428,7 +430,47 @@ func (s *order) AdminOrderPage( query = query.Group(tbl.ID) } - items, total, err := query.Where(conds...).Order(tbl.ID.Desc()).FindByPage(int(filter.Offset()), int(filter.Limit)) + // 排序白名单:避免把任意字符串拼进 SQL 导致注入或慢查询。 + // 约定:只允许按以下字段排序;未指定时默认按 id desc。 + orderBys := make([]field.Expr, 0, 4) + allowedAsc := map[string]field.Expr{ + "id": tbl.ID.Asc(), + "created_at": tbl.CreatedAt.Asc(), + "paid_at": tbl.PaidAt.Asc(), + "amount_paid": tbl.AmountPaid.Asc(), + } + allowedDesc := map[string]field.Expr{ + "id": tbl.ID.Desc(), + "created_at": tbl.CreatedAt.Desc(), + "paid_at": tbl.PaidAt.Desc(), + "amount_paid": tbl.AmountPaid.Desc(), + } + for _, f := range filter.AscFields() { + f = strings.TrimSpace(f) + if f == "" { + continue + } + if ob, ok := allowedAsc[f]; ok { + orderBys = append(orderBys, ob) + } + } + for _, f := range filter.DescFields() { + f = strings.TrimSpace(f) + if f == "" { + continue + } + if ob, ok := allowedDesc[f]; ok { + orderBys = append(orderBys, ob) + } + } + // 默认加上 id desc 作为稳定排序(尤其是 join + group 的场景)。 + if len(orderBys) == 0 { + orderBys = append(orderBys, tbl.ID.Desc()) + } else { + orderBys = append(orderBys, tbl.ID.Desc()) + } + + items, total, err := query.Where(conds...).Order(orderBys...).FindByPage(int(filter.Offset()), int(filter.Limit)) if err != nil { return nil, err } diff --git a/backend/app/services/order_test.go b/backend/app/services/order_test.go index 1a1cce1..b88aba7 100644 --- a/backend/app/services/order_test.go +++ b/backend/app/services/order_test.go @@ -12,6 +12,7 @@ import ( "quyun/v2/app/commands/testx" "quyun/v2/app/errorx" "quyun/v2/app/http/tenant/dto" + "quyun/v2/app/requests" "quyun/v2/database" "quyun/v2/database/models" "quyun/v2/pkg/consts" @@ -590,6 +591,58 @@ func (s *OrderTestSuite) Test_AdminOrderPage() { So(pager.Total, ShouldEqual, 1) }) + Convey("按排序字段(asc/desc)排序(白名单)", func() { + s.truncate(ctx, models.TableNameOrderItem, models.TableNameOrder) + + o1 := &models.Order{ + TenantID: tenantID, + UserID: 2, + Type: consts.OrderTypeContentPurchase, + Status: consts.OrderStatusPaid, + Currency: consts.CurrencyCNY, + AmountPaid: 500, + Snapshot: types.JSON([]byte("{}")), + PaidAt: now, + CreatedAt: now.Add(-time.Hour), + UpdatedAt: now.Add(-time.Hour), + } + So(o1.Create(ctx), ShouldBeNil) + + o2 := &models.Order{ + TenantID: tenantID, + UserID: 3, + Type: consts.OrderTypeContentPurchase, + Status: consts.OrderStatusPaid, + Currency: consts.CurrencyCNY, + AmountPaid: 100, + Snapshot: types.JSON([]byte("{}")), + PaidAt: now, + CreatedAt: now, + UpdatedAt: now, + } + So(o2.Create(ctx), ShouldBeNil) + + asc := "amount_paid" + pagerAsc, err := Order.AdminOrderPage(ctx, tenantID, &dto.AdminOrderListFilter{ + SortQueryFilter: requests.SortQueryFilter{Asc: &asc}, + }) + So(err, ShouldBeNil) + So(pagerAsc.Total, ShouldEqual, 2) + itemsAsc, ok := pagerAsc.Items.([]*models.Order) + So(ok, ShouldBeTrue) + So(itemsAsc[0].AmountPaid, ShouldEqual, 100) + + desc := "created_at" + pagerDesc, err := Order.AdminOrderPage(ctx, tenantID, &dto.AdminOrderListFilter{ + SortQueryFilter: requests.SortQueryFilter{Desc: &desc}, + }) + So(err, ShouldBeNil) + So(pagerDesc.Total, ShouldEqual, 2) + itemsDesc, ok := pagerDesc.Items.([]*models.Order) + So(ok, ShouldBeTrue) + So(itemsDesc[0].CreatedAt.After(itemsDesc[1].CreatedAt), ShouldBeTrue) + }) + Convey("按 type 过滤", func() { s.truncate(ctx, models.TableNameOrderItem, models.TableNameOrder) diff --git a/backend/database/migrations/20251218193000_orders_admin_list_indexes.sql b/backend/database/migrations/20251218193000_orders_admin_list_indexes.sql new file mode 100644 index 0000000..3c9467d --- /dev/null +++ b/backend/database/migrations/20251218193000_orders_admin_list_indexes.sql @@ -0,0 +1,11 @@ +-- +goose Up +-- +goose StatementBegin +-- orders 列表查询索引补齐:租户管理端常用按 created_at/type 过滤 + 排序。 +CREATE INDEX IF NOT EXISTS ix_orders_tenant_created_at ON orders(tenant_id, created_at); +CREATE INDEX IF NOT EXISTS ix_orders_tenant_type ON orders(tenant_id, type); +-- +goose StatementEnd +-- +goose Down +-- +goose StatementBegin +DROP INDEX IF EXISTS ix_orders_tenant_type; +DROP INDEX IF EXISTS ix_orders_tenant_created_at; +-- +goose StatementEnd diff --git a/backend/docs/docs.go b/backend/docs/docs.go index 01da82d..b485c4c 100644 --- a/backend/docs/docs.go +++ b/backend/docs/docs.go @@ -971,6 +971,12 @@ const docTemplate = `{ "name": "amount_paid_min", "in": "query" }, + { + "type": "string", + "description": "Asc specifies comma-separated field names to sort ascending by.", + "name": "asc", + "in": "query" + }, { "type": "integer", "description": "ContentID 内容ID(可选):通过 order_items 关联过滤。", @@ -995,6 +1001,12 @@ const docTemplate = `{ "name": "created_at_to", "in": "query" }, + { + "type": "string", + "description": "Desc specifies comma-separated field names to sort descending by.", + "name": "desc", + "in": "query" + }, { "type": "integer", "description": "Limit is page size; only values in {10,20,50,100} are accepted (otherwise defaults to 10).", diff --git a/backend/docs/swagger.json b/backend/docs/swagger.json index 7b5413b..0bb6e18 100644 --- a/backend/docs/swagger.json +++ b/backend/docs/swagger.json @@ -965,6 +965,12 @@ "name": "amount_paid_min", "in": "query" }, + { + "type": "string", + "description": "Asc specifies comma-separated field names to sort ascending by.", + "name": "asc", + "in": "query" + }, { "type": "integer", "description": "ContentID 内容ID(可选):通过 order_items 关联过滤。", @@ -989,6 +995,12 @@ "name": "created_at_to", "in": "query" }, + { + "type": "string", + "description": "Desc specifies comma-separated field names to sort descending by.", + "name": "desc", + "in": "query" + }, { "type": "integer", "description": "Limit is page size; only values in {10,20,50,100} are accepted (otherwise defaults to 10).", diff --git a/backend/docs/swagger.yaml b/backend/docs/swagger.yaml index 1242ac5..058a8a9 100644 --- a/backend/docs/swagger.yaml +++ b/backend/docs/swagger.yaml @@ -1775,6 +1775,10 @@ paths: in: query name: amount_paid_min type: integer + - description: Asc specifies comma-separated field names to sort ascending by. + in: query + name: asc + type: string - description: ContentID 内容ID(可选):通过 order_items 关联过滤。 in: query name: content_id @@ -1791,6 +1795,11 @@ paths: in: query name: created_at_to type: string + - description: Desc specifies comma-separated field names to sort descending + by. + in: query + name: desc + type: string - description: Limit is page size; only values in {10,20,50,100} are accepted (otherwise defaults to 10). in: query diff --git a/backend/tests/tenant.http b/backend/tests/tenant.http index 0f81894..02e2eee 100644 --- a/backend/tests/tenant.http +++ b/backend/tests/tenant.http @@ -142,7 +142,7 @@ Content-Type: application/json Authorization: Bearer {{ token }} ### Tenant Admin - Orders list (filter by username/content_title/created_at/type) -GET {{ host }}/t/{{ tenantCode }}/v1/admin/orders?page=1&limit=10&username=alice&content_title=Go&created_at_from=2025-01-01T00:00:00Z&created_at_to=2026-01-01T00:00:00Z&type=content_purchase +GET {{ host }}/t/{{ tenantCode }}/v1/admin/orders?page=1&limit=10&username=alice&content_title=Go&created_at_from=2025-01-01T00:00:00Z&created_at_to=2026-01-01T00:00:00Z&type=content_purchase&asc=amount_paid&desc=paid_at Content-Type: application/json Authorization: Bearer {{ token }}