feat: 增加订单过滤功能,支持按内容ID、支付时间范围和支付金额范围筛选

This commit is contained in:
2025-12-18 16:46:40 +08:00
parent 3249e405ac
commit e268176af5
8 changed files with 418 additions and 37 deletions

View File

@@ -1,6 +1,8 @@
package dto
import (
"time"
"quyun/v2/app/requests"
"quyun/v2/database/models"
"quyun/v2/pkg/consts"
@@ -12,8 +14,18 @@ type AdminOrderListFilter struct {
requests.Pagination `json:",inline" query:",inline"`
// UserID filters orders by buyer user id.
UserID *int64 `json:"user_id,omitempty" query:"user_id"`
// ContentID filters orders by purchased content id (via order_items join).
ContentID *int64 `json:"content_id,omitempty" query:"content_id"`
// Status filters orders by order status.
Status *consts.OrderStatus `json:"status,omitempty" query:"status"`
// PaidAtFrom filters orders by paid_at >= this time.
PaidAtFrom *time.Time `json:"paid_at_from,omitempty" query:"paid_at_from"`
// PaidAtTo filters orders by paid_at <= this time.
PaidAtTo *time.Time `json:"paid_at_to,omitempty" query:"paid_at_to"`
// AmountPaidMin filters orders by amount_paid >= this amount (cents).
AmountPaidMin *int64 `json:"amount_paid_min,omitempty" query:"amount_paid_min"`
// AmountPaidMax filters orders by amount_paid <= this amount (cents).
AmountPaidMax *int64 `json:"amount_paid_max,omitempty" query:"amount_paid_max"`
}
// AdminOrderRefundForm defines payload for tenant-admin to refund an order.

View File

@@ -1,6 +1,8 @@
package dto
import (
"time"
"quyun/v2/app/requests"
"quyun/v2/pkg/consts"
)
@@ -11,4 +13,10 @@ type MyOrderListFilter struct {
requests.Pagination `json:",inline" query:",inline"`
// Status filters orders by order status.
Status *consts.OrderStatus `json:"status,omitempty" query:"status"`
// PaidAtFrom filters orders by paid_at >= this time.
PaidAtFrom *time.Time `json:"paid_at_from,omitempty" query:"paid_at_from"`
// PaidAtTo filters orders by paid_at <= this time.
PaidAtTo *time.Time `json:"paid_at_to,omitempty" query:"paid_at_to"`
// ContentID filters orders by purchased content id (via order_items join).
ContentID *int64 `json:"content_id,omitempty" query:"content_id"`
}

View File

@@ -23,42 +23,74 @@ import (
"go.ipao.vip/gen/types"
)
// PurchaseOrderSnapshot 为“内容购买订单”的下单快照(用于历史展示与争议审计)。
type PurchaseOrderSnapshot struct {
ContentID int64 `json:"content_id"`
ContentTitle string `json:"content_title"`
ContentUserID int64 `json:"content_user_id"`
ContentVisibility consts.ContentVisibility `json:"content_visibility"`
PreviewSeconds int32 `json:"preview_seconds"`
PreviewDownloadable bool `json:"preview_downloadable"`
Currency consts.Currency `json:"currency"`
PriceAmount int64 `json:"price_amount"`
DiscountType consts.DiscountType `json:"discount_type"`
DiscountValue int64 `json:"discount_value"`
DiscountStartAt *time.Time `json:"discount_start_at,omitempty"`
DiscountEndAt *time.Time `json:"discount_end_at,omitempty"`
AmountOriginal int64 `json:"amount_original"`
AmountDiscount int64 `json:"amount_discount"`
AmountPaid int64 `json:"amount_paid"`
PurchaseAt time.Time `json:"purchase_at"`
PurchaseIdempotency string `json:"purchase_idempotency_key,omitempty"`
PurchasePricingNotes string `json:"purchase_pricing_notes,omitempty"`
// ContentID 内容ID。
ContentID int64 `json:"content_id"`
// ContentTitle 内容标题(下单时快照,避免事后改名影响历史订单展示)。
ContentTitle string `json:"content_title"`
// ContentUserID 内容作者用户ID用于审计与后续分成扩展
ContentUserID int64 `json:"content_user_id"`
// ContentVisibility 下单时的可见性快照。
ContentVisibility consts.ContentVisibility `json:"content_visibility"`
// PreviewSeconds 下单时的试看秒数快照。
PreviewSeconds int32 `json:"preview_seconds"`
// PreviewDownloadable 下单时的试看是否可下载快照(当前固定为 false
PreviewDownloadable bool `json:"preview_downloadable"`
// Currency 币种:当前固定 CNY金额单位为分
Currency consts.Currency `json:"currency"`
// PriceAmount 基础价格(分)。
PriceAmount int64 `json:"price_amount"`
// DiscountType 折扣类型none/percent/amount
DiscountType consts.DiscountType `json:"discount_type"`
// DiscountValue 折扣值percent=0-100amount=分)。
DiscountValue int64 `json:"discount_value"`
// DiscountStartAt 折扣开始时间(可选)。
DiscountStartAt *time.Time `json:"discount_start_at,omitempty"`
// DiscountEndAt 折扣结束时间(可选)。
DiscountEndAt *time.Time `json:"discount_end_at,omitempty"`
// AmountOriginal 原价金额(分)。
AmountOriginal int64 `json:"amount_original"`
// AmountDiscount 优惠金额(分)。
AmountDiscount int64 `json:"amount_discount"`
// AmountPaid 实付金额(分)。
AmountPaid int64 `json:"amount_paid"`
// PurchaseAt 下单时间(逻辑时间)。
PurchaseAt time.Time `json:"purchase_at"`
// PurchaseIdempotency 幂等键(可选)。
PurchaseIdempotency string `json:"purchase_idempotency_key,omitempty"`
// PurchasePricingNotes 价格计算补充说明(可选,便于排查争议)。
PurchasePricingNotes string `json:"purchase_pricing_notes,omitempty"`
}
// OrderItemSnapshot 为“订单明细”的内容快照。
type OrderItemSnapshot struct {
ContentID int64 `json:"content_id"`
ContentTitle string `json:"content_title"`
ContentUserID int64 `json:"content_user_id"`
AmountPaid int64 `json:"amount_paid"`
// ContentID 内容ID。
ContentID int64 `json:"content_id"`
// ContentTitle 内容标题快照。
ContentTitle string `json:"content_title"`
// ContentUserID 内容作者用户ID。
ContentUserID int64 `json:"content_user_id"`
// AmountPaid 该行实付金额(分)。
AmountPaid int64 `json:"amount_paid"`
}
// TopupOrderSnapshot 为“后台充值订单”的快照(用于审计与追责)。
type TopupOrderSnapshot struct {
OperatorUserID int64 `json:"operator_user_id"`
TargetUserID int64 `json:"target_user_id"`
Amount int64 `json:"amount"`
Currency consts.Currency `json:"currency"`
Reason string `json:"reason,omitempty"`
IdempotencyKey string `json:"idempotency_key,omitempty"`
TopupAt time.Time `json:"topup_at"`
// OperatorUserID 充值操作人用户ID租户管理员
OperatorUserID int64 `json:"operator_user_id"`
// TargetUserID 充值目标用户ID租户成员
TargetUserID int64 `json:"target_user_id"`
// Amount 充值金额(分)。
Amount int64 `json:"amount"`
// Currency 币种:当前固定 CNY金额单位为分
Currency consts.Currency `json:"currency"`
// Reason 充值原因(可选,强烈建议填写用于审计)。
Reason string `json:"reason,omitempty"`
// IdempotencyKey 幂等键(可选)。
IdempotencyKey string `json:"idempotency_key,omitempty"`
// TopupAt 充值时间(逻辑时间)。
TopupAt time.Time `json:"topup_at"`
}
// PurchaseContentParams 定义“租户内使用余额购买内容”的入参。
@@ -237,9 +269,10 @@ func (s *order) MyOrderPage(
}
logrus.WithFields(logrus.Fields{
"tenant_id": tenantID,
"user_id": userID,
"status": lo.FromPtr(filter.Status),
"tenant_id": tenantID,
"user_id": userID,
"status": lo.FromPtr(filter.Status),
"content_id": lo.FromPtr(filter.ContentID),
}).Info("services.order.me.page")
filter.Pagination.Format()
@@ -254,6 +287,18 @@ func (s *order) MyOrderPage(
if filter.Status != nil {
conds = append(conds, tbl.Status.Eq(*filter.Status))
}
if filter.PaidAtFrom != nil {
conds = append(conds, tbl.PaidAt.Gte(*filter.PaidAtFrom))
}
if filter.PaidAtTo != nil {
conds = append(conds, tbl.PaidAt.Lte(*filter.PaidAtTo))
}
if filter.ContentID != nil && *filter.ContentID > 0 {
oiTbl, _ := models.OrderItemQuery.QueryContext(ctx)
query = query.LeftJoin(oiTbl, oiTbl.OrderID.EqCol(tbl.ID))
conds = append(conds, oiTbl.ContentID.Eq(*filter.ContentID))
query = query.Group(tbl.ID)
}
items, total, err := query.Where(conds...).Order(tbl.ID.Desc()).FindByPage(int(filter.Offset()), int(filter.Limit))
if err != nil {
@@ -305,9 +350,10 @@ func (s *order) AdminOrderPage(
}
logrus.WithFields(logrus.Fields{
"tenant_id": tenantID,
"user_id": lo.FromPtr(filter.UserID),
"status": lo.FromPtr(filter.Status),
"tenant_id": tenantID,
"user_id": lo.FromPtr(filter.UserID),
"status": lo.FromPtr(filter.Status),
"content_id": lo.FromPtr(filter.ContentID),
}).Info("services.order.admin.page")
filter.Pagination.Format()
@@ -322,6 +368,24 @@ func (s *order) AdminOrderPage(
if filter.Status != nil {
conds = append(conds, tbl.Status.Eq(*filter.Status))
}
if filter.PaidAtFrom != nil {
conds = append(conds, tbl.PaidAt.Gte(*filter.PaidAtFrom))
}
if filter.PaidAtTo != nil {
conds = append(conds, tbl.PaidAt.Lte(*filter.PaidAtTo))
}
if filter.AmountPaidMin != nil {
conds = append(conds, tbl.AmountPaid.Gte(*filter.AmountPaidMin))
}
if filter.AmountPaidMax != nil {
conds = append(conds, tbl.AmountPaid.Lte(*filter.AmountPaidMax))
}
if filter.ContentID != nil && *filter.ContentID > 0 {
oiTbl, _ := models.OrderItemQuery.QueryContext(ctx)
query = query.LeftJoin(oiTbl, oiTbl.OrderID.EqCol(tbl.ID))
conds = append(conds, oiTbl.ContentID.Eq(*filter.ContentID))
query = query.Group(tbl.ID)
}
items, total, err := query.Where(conds...).Order(tbl.ID.Desc()).FindByPage(int(filter.Offset()), int(filter.Limit))
if err != nil {

View File

@@ -16,6 +16,7 @@ import (
"quyun/v2/database/models"
"quyun/v2/pkg/consts"
"github.com/samber/lo"
. "github.com/smartystreets/goconvey/convey"
"github.com/stretchr/testify/suite"
@@ -184,10 +185,11 @@ func (s *OrderTestSuite) Test_AdminTopupUser() {
func (s *OrderTestSuite) Test_MyOrderPage() {
Convey("Order.MyOrderPage", s.T(), func() {
ctx := s.T().Context()
now := time.Now().UTC()
tenantID := int64(1)
userID := int64(2)
s.truncate(ctx, models.TableNameOrder, models.TableNameOrderItem)
s.truncate(ctx, models.TableNameOrderItem, models.TableNameOrder)
Convey("参数非法应返回错误", func() {
_, err := Order.MyOrderPage(ctx, 0, userID, &dto.MyOrderListFilter{})
@@ -199,6 +201,64 @@ func (s *OrderTestSuite) Test_MyOrderPage() {
So(err, ShouldBeNil)
So(pager.Total, ShouldEqual, 0)
})
Convey("按 content_id 过滤", func() {
o1 := &models.Order{
TenantID: tenantID,
UserID: userID,
Type: consts.OrderTypeContentPurchase,
Status: consts.OrderStatusPaid,
Currency: consts.CurrencyCNY,
AmountPaid: 100,
Snapshot: types.JSON([]byte("{}")),
PaidAt: now,
CreatedAt: now,
UpdatedAt: now,
}
So(o1.Create(ctx), ShouldBeNil)
So((&models.OrderItem{
TenantID: tenantID,
UserID: userID,
OrderID: o1.ID,
ContentID: 111,
ContentUserID: 1,
AmountPaid: 100,
Snapshot: types.JSON([]byte("{}")),
CreatedAt: now,
UpdatedAt: now,
}).Create(ctx), ShouldBeNil)
o2 := &models.Order{
TenantID: tenantID,
UserID: userID,
Type: consts.OrderTypeContentPurchase,
Status: consts.OrderStatusPaid,
Currency: consts.CurrencyCNY,
AmountPaid: 200,
Snapshot: types.JSON([]byte("{}")),
PaidAt: now.Add(time.Minute),
CreatedAt: now.Add(time.Minute),
UpdatedAt: now.Add(time.Minute),
}
So(o2.Create(ctx), ShouldBeNil)
So((&models.OrderItem{
TenantID: tenantID,
UserID: userID,
OrderID: o2.ID,
ContentID: 222,
ContentUserID: 1,
AmountPaid: 200,
Snapshot: types.JSON([]byte("{}")),
CreatedAt: now.Add(time.Minute),
UpdatedAt: now.Add(time.Minute),
}).Create(ctx), ShouldBeNil)
pager, err := Order.MyOrderPage(ctx, tenantID, userID, &dto.MyOrderListFilter{
ContentID: lo.ToPtr(int64(111)),
})
So(err, ShouldBeNil)
So(pager.Total, ShouldEqual, 1)
})
})
}
@@ -225,9 +285,10 @@ func (s *OrderTestSuite) Test_MyOrderDetail() {
func (s *OrderTestSuite) Test_AdminOrderPage() {
Convey("Order.AdminOrderPage", s.T(), func() {
ctx := s.T().Context()
now := time.Now().UTC()
tenantID := int64(1)
s.truncate(ctx, models.TableNameOrder, models.TableNameOrderItem)
s.truncate(ctx, models.TableNameOrderItem, models.TableNameOrder)
Convey("参数非法应返回错误", func() {
_, err := Order.AdminOrderPage(ctx, 0, &dto.AdminOrderListFilter{})
@@ -239,6 +300,102 @@ func (s *OrderTestSuite) Test_AdminOrderPage() {
So(err, ShouldBeNil)
So(pager.Total, ShouldEqual, 0)
})
Convey("按 paid_at 时间窗过滤", func() {
o1 := &models.Order{
TenantID: tenantID,
UserID: 2,
Type: consts.OrderTypeContentPurchase,
Status: consts.OrderStatusPaid,
Currency: consts.CurrencyCNY,
AmountPaid: 100,
Snapshot: types.JSON([]byte("{}")),
PaidAt: now.Add(-time.Hour),
CreatedAt: now.Add(-time.Hour),
UpdatedAt: now.Add(-time.Hour),
}
So(o1.Create(ctx), ShouldBeNil)
So((&models.OrderItem{
TenantID: tenantID,
UserID: 2,
OrderID: o1.ID,
ContentID: 333,
ContentUserID: 1,
AmountPaid: 100,
Snapshot: types.JSON([]byte("{}")),
CreatedAt: now.Add(-time.Hour),
UpdatedAt: now.Add(-time.Hour),
}).Create(ctx), ShouldBeNil)
o2 := &models.Order{
TenantID: tenantID,
UserID: 3,
Type: consts.OrderTypeContentPurchase,
Status: consts.OrderStatusPaid,
Currency: consts.CurrencyCNY,
AmountPaid: 200,
Snapshot: types.JSON([]byte("{}")),
PaidAt: now,
CreatedAt: now,
UpdatedAt: now,
}
So(o2.Create(ctx), ShouldBeNil)
So((&models.OrderItem{
TenantID: tenantID,
UserID: 3,
OrderID: o2.ID,
ContentID: 444,
ContentUserID: 1,
AmountPaid: 200,
Snapshot: types.JSON([]byte("{}")),
CreatedAt: now,
UpdatedAt: now,
}).Create(ctx), ShouldBeNil)
from := now.Add(-10 * time.Minute)
to := now.Add(10 * time.Minute)
pager, err := Order.AdminOrderPage(ctx, tenantID, &dto.AdminOrderListFilter{
PaidAtFrom: &from,
PaidAtTo: &to,
})
So(err, ShouldBeNil)
So(pager.Total, ShouldEqual, 1)
})
Convey("按 content_id 过滤", 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: 100,
Snapshot: types.JSON([]byte("{}")),
PaidAt: now,
CreatedAt: now,
UpdatedAt: now,
}
So(o1.Create(ctx), ShouldBeNil)
So((&models.OrderItem{
TenantID: tenantID,
UserID: 2,
OrderID: o1.ID,
ContentID: 555,
ContentUserID: 1,
AmountPaid: 100,
Snapshot: types.JSON([]byte("{}")),
CreatedAt: now,
UpdatedAt: now,
}).Create(ctx), ShouldBeNil)
pager, err := Order.AdminOrderPage(ctx, tenantID, &dto.AdminOrderListFilter{
ContentID: lo.ToPtr(int64(555)),
})
So(err, ShouldBeNil)
So(pager.Total, ShouldEqual, 1)
})
})
}

View File

@@ -623,6 +623,24 @@ const docTemplate = `{
"in": "path",
"required": true
},
{
"type": "integer",
"description": "AmountPaidMax filters orders by amount_paid \u003c= this amount (cents).",
"name": "amount_paid_max",
"in": "query"
},
{
"type": "integer",
"description": "AmountPaidMin filters orders by amount_paid \u003e= this amount (cents).",
"name": "amount_paid_min",
"in": "query"
},
{
"type": "integer",
"description": "ContentID filters orders by purchased content id (via order_items join).",
"name": "content_id",
"in": "query"
},
{
"type": "integer",
"description": "Limit is page size; only values in {10,20,50,100} are accepted (otherwise defaults to 10).",
@@ -635,6 +653,18 @@ const docTemplate = `{
"name": "page",
"in": "query"
},
{
"type": "string",
"description": "PaidAtFrom filters orders by paid_at \u003e= this time.",
"name": "paid_at_from",
"in": "query"
},
{
"type": "string",
"description": "PaidAtTo filters orders by paid_at \u003c= this time.",
"name": "paid_at_to",
"in": "query"
},
{
"enum": [
"created",
@@ -1172,6 +1202,12 @@ const docTemplate = `{
"in": "path",
"required": true
},
{
"type": "integer",
"description": "ContentID filters orders by purchased content id (via order_items join).",
"name": "content_id",
"in": "query"
},
{
"type": "integer",
"description": "Limit is page size; only values in {10,20,50,100} are accepted (otherwise defaults to 10).",
@@ -1184,6 +1220,18 @@ const docTemplate = `{
"name": "page",
"in": "query"
},
{
"type": "string",
"description": "PaidAtFrom filters orders by paid_at \u003e= this time.",
"name": "paid_at_from",
"in": "query"
},
{
"type": "string",
"description": "PaidAtTo filters orders by paid_at \u003c= this time.",
"name": "paid_at_to",
"in": "query"
},
{
"enum": [
"created",

View File

@@ -617,6 +617,24 @@
"in": "path",
"required": true
},
{
"type": "integer",
"description": "AmountPaidMax filters orders by amount_paid \u003c= this amount (cents).",
"name": "amount_paid_max",
"in": "query"
},
{
"type": "integer",
"description": "AmountPaidMin filters orders by amount_paid \u003e= this amount (cents).",
"name": "amount_paid_min",
"in": "query"
},
{
"type": "integer",
"description": "ContentID filters orders by purchased content id (via order_items join).",
"name": "content_id",
"in": "query"
},
{
"type": "integer",
"description": "Limit is page size; only values in {10,20,50,100} are accepted (otherwise defaults to 10).",
@@ -629,6 +647,18 @@
"name": "page",
"in": "query"
},
{
"type": "string",
"description": "PaidAtFrom filters orders by paid_at \u003e= this time.",
"name": "paid_at_from",
"in": "query"
},
{
"type": "string",
"description": "PaidAtTo filters orders by paid_at \u003c= this time.",
"name": "paid_at_to",
"in": "query"
},
{
"enum": [
"created",
@@ -1166,6 +1196,12 @@
"in": "path",
"required": true
},
{
"type": "integer",
"description": "ContentID filters orders by purchased content id (via order_items join).",
"name": "content_id",
"in": "query"
},
{
"type": "integer",
"description": "Limit is page size; only values in {10,20,50,100} are accepted (otherwise defaults to 10).",
@@ -1178,6 +1214,18 @@
"name": "page",
"in": "query"
},
{
"type": "string",
"description": "PaidAtFrom filters orders by paid_at \u003e= this time.",
"name": "paid_at_from",
"in": "query"
},
{
"type": "string",
"description": "PaidAtTo filters orders by paid_at \u003c= this time.",
"name": "paid_at_to",
"in": "query"
},
{
"enum": [
"created",

View File

@@ -1386,6 +1386,19 @@ paths:
name: tenantCode
required: true
type: string
- description: AmountPaidMax filters orders by amount_paid <= this amount (cents).
in: query
name: amount_paid_max
type: integer
- description: AmountPaidMin filters orders by amount_paid >= this amount (cents).
in: query
name: amount_paid_min
type: integer
- description: ContentID filters orders by purchased content id (via order_items
join).
in: query
name: content_id
type: integer
- description: Limit is page size; only values in {10,20,50,100} are accepted
(otherwise defaults to 10).
in: query
@@ -1395,6 +1408,14 @@ paths:
in: query
name: page
type: integer
- description: PaidAtFrom filters orders by paid_at >= this time.
in: query
name: paid_at_from
type: string
- description: PaidAtTo filters orders by paid_at <= this time.
in: query
name: paid_at_to
type: string
- description: Status filters orders by order status.
enum:
- created
@@ -1745,6 +1766,11 @@ paths:
name: tenantCode
required: true
type: string
- description: ContentID filters orders by purchased content id (via order_items
join).
in: query
name: content_id
type: integer
- description: Limit is page size; only values in {10,20,50,100} are accepted
(otherwise defaults to 10).
in: query
@@ -1754,6 +1780,14 @@ paths:
in: query
name: page
type: integer
- description: PaidAtFrom filters orders by paid_at >= this time.
in: query
name: paid_at_from
type: string
- description: PaidAtTo filters orders by paid_at <= this time.
in: query
name: paid_at_to
type: string
- description: Status filters orders by order status.
enum:
- created

View File

@@ -57,6 +57,11 @@ GET {{ host }}/t/{{ tenantCode }}/v1/orders?page=1&limit=10
Content-Type: application/json
Authorization: Bearer {{ token }}
### Tenant - My orders list (filter by status/content_id/paid_at window)
GET {{ host }}/t/{{ tenantCode }}/v1/orders?page=1&limit=10&status=paid&content_id={{ contentID }}
Content-Type: application/json
Authorization: Bearer {{ token }}
### Tenant - My order detail
GET {{ host }}/t/{{ tenantCode }}/v1/orders/{{ orderID }}
Content-Type: application/json
@@ -113,6 +118,11 @@ GET {{ host }}/t/{{ tenantCode }}/v1/admin/orders?page=1&limit=10
Content-Type: application/json
Authorization: Bearer {{ token }}
### Tenant Admin - Orders list (filter by user_id/content_id/paid_at range/amount_paid range)
GET {{ host }}/t/{{ tenantCode }}/v1/admin/orders?page=1&limit=10&user_id=2&content_id={{ contentID }}&paid_at_from=2025-01-01T00:00:00Z&paid_at_to=2026-01-01T00:00:00Z&amount_paid_min=1&amount_paid_max=99999999
Content-Type: application/json
Authorization: Bearer {{ token }}
### Tenant Admin - Order detail
@orderID = 1
GET {{ host }}/t/{{ tenantCode }}/v1/admin/orders/{{ orderID }}