tenant: admin orders sort whitelist
This commit is contained in:
@@ -14,6 +14,9 @@ type AdminOrderListFilter struct {
|
|||||||
// Pagination 分页参数:page/limit(通用)。
|
// Pagination 分页参数:page/limit(通用)。
|
||||||
requests.Pagination `json:",inline" query:",inline"`
|
requests.Pagination `json:",inline" query:",inline"`
|
||||||
|
|
||||||
|
// SortQueryFilter 排序参数:asc/desc(逗号分隔字段名);字段白名单在 service 层统一校验。
|
||||||
|
requests.SortQueryFilter `json:",inline" query:",inline"`
|
||||||
|
|
||||||
// UserID 下单用户ID(可选):按买家用户ID精确过滤。
|
// UserID 下单用户ID(可选):按买家用户ID精确过滤。
|
||||||
UserID *int64 `json:"user_id,omitempty" query:"user_id"`
|
UserID *int64 `json:"user_id,omitempty" query:"user_id"`
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import (
|
|||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"quyun/v2/app/errorx"
|
"quyun/v2/app/errorx"
|
||||||
@@ -18,6 +19,7 @@ import (
|
|||||||
"github.com/samber/lo"
|
"github.com/samber/lo"
|
||||||
"github.com/sirupsen/logrus"
|
"github.com/sirupsen/logrus"
|
||||||
"go.ipao.vip/gen"
|
"go.ipao.vip/gen"
|
||||||
|
"go.ipao.vip/gen/field"
|
||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
"gorm.io/gorm/clause"
|
"gorm.io/gorm/clause"
|
||||||
|
|
||||||
@@ -428,7 +430,47 @@ func (s *order) AdminOrderPage(
|
|||||||
query = query.Group(tbl.ID)
|
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 {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import (
|
|||||||
"quyun/v2/app/commands/testx"
|
"quyun/v2/app/commands/testx"
|
||||||
"quyun/v2/app/errorx"
|
"quyun/v2/app/errorx"
|
||||||
"quyun/v2/app/http/tenant/dto"
|
"quyun/v2/app/http/tenant/dto"
|
||||||
|
"quyun/v2/app/requests"
|
||||||
"quyun/v2/database"
|
"quyun/v2/database"
|
||||||
"quyun/v2/database/models"
|
"quyun/v2/database/models"
|
||||||
"quyun/v2/pkg/consts"
|
"quyun/v2/pkg/consts"
|
||||||
@@ -590,6 +591,58 @@ func (s *OrderTestSuite) Test_AdminOrderPage() {
|
|||||||
So(pager.Total, ShouldEqual, 1)
|
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() {
|
Convey("按 type 过滤", func() {
|
||||||
s.truncate(ctx, models.TableNameOrderItem, models.TableNameOrder)
|
s.truncate(ctx, models.TableNameOrderItem, models.TableNameOrder)
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -971,6 +971,12 @@ const docTemplate = `{
|
|||||||
"name": "amount_paid_min",
|
"name": "amount_paid_min",
|
||||||
"in": "query"
|
"in": "query"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"type": "string",
|
||||||
|
"description": "Asc specifies comma-separated field names to sort ascending by.",
|
||||||
|
"name": "asc",
|
||||||
|
"in": "query"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"type": "integer",
|
"type": "integer",
|
||||||
"description": "ContentID 内容ID(可选):通过 order_items 关联过滤。",
|
"description": "ContentID 内容ID(可选):通过 order_items 关联过滤。",
|
||||||
@@ -995,6 +1001,12 @@ const docTemplate = `{
|
|||||||
"name": "created_at_to",
|
"name": "created_at_to",
|
||||||
"in": "query"
|
"in": "query"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"type": "string",
|
||||||
|
"description": "Desc specifies comma-separated field names to sort descending by.",
|
||||||
|
"name": "desc",
|
||||||
|
"in": "query"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"type": "integer",
|
"type": "integer",
|
||||||
"description": "Limit is page size; only values in {10,20,50,100} are accepted (otherwise defaults to 10).",
|
"description": "Limit is page size; only values in {10,20,50,100} are accepted (otherwise defaults to 10).",
|
||||||
|
|||||||
@@ -965,6 +965,12 @@
|
|||||||
"name": "amount_paid_min",
|
"name": "amount_paid_min",
|
||||||
"in": "query"
|
"in": "query"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"type": "string",
|
||||||
|
"description": "Asc specifies comma-separated field names to sort ascending by.",
|
||||||
|
"name": "asc",
|
||||||
|
"in": "query"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"type": "integer",
|
"type": "integer",
|
||||||
"description": "ContentID 内容ID(可选):通过 order_items 关联过滤。",
|
"description": "ContentID 内容ID(可选):通过 order_items 关联过滤。",
|
||||||
@@ -989,6 +995,12 @@
|
|||||||
"name": "created_at_to",
|
"name": "created_at_to",
|
||||||
"in": "query"
|
"in": "query"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"type": "string",
|
||||||
|
"description": "Desc specifies comma-separated field names to sort descending by.",
|
||||||
|
"name": "desc",
|
||||||
|
"in": "query"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"type": "integer",
|
"type": "integer",
|
||||||
"description": "Limit is page size; only values in {10,20,50,100} are accepted (otherwise defaults to 10).",
|
"description": "Limit is page size; only values in {10,20,50,100} are accepted (otherwise defaults to 10).",
|
||||||
|
|||||||
@@ -1775,6 +1775,10 @@ paths:
|
|||||||
in: query
|
in: query
|
||||||
name: amount_paid_min
|
name: amount_paid_min
|
||||||
type: integer
|
type: integer
|
||||||
|
- description: Asc specifies comma-separated field names to sort ascending by.
|
||||||
|
in: query
|
||||||
|
name: asc
|
||||||
|
type: string
|
||||||
- description: ContentID 内容ID(可选):通过 order_items 关联过滤。
|
- description: ContentID 内容ID(可选):通过 order_items 关联过滤。
|
||||||
in: query
|
in: query
|
||||||
name: content_id
|
name: content_id
|
||||||
@@ -1791,6 +1795,11 @@ paths:
|
|||||||
in: query
|
in: query
|
||||||
name: created_at_to
|
name: created_at_to
|
||||||
type: string
|
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
|
- description: Limit is page size; only values in {10,20,50,100} are accepted
|
||||||
(otherwise defaults to 10).
|
(otherwise defaults to 10).
|
||||||
in: query
|
in: query
|
||||||
|
|||||||
@@ -142,7 +142,7 @@ Content-Type: application/json
|
|||||||
Authorization: Bearer {{ token }}
|
Authorization: Bearer {{ token }}
|
||||||
|
|
||||||
### Tenant Admin - Orders list (filter by username/content_title/created_at/type)
|
### 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
|
Content-Type: application/json
|
||||||
Authorization: Bearer {{ token }}
|
Authorization: Bearer {{ token }}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user