diff --git a/backend/app/http/tenant/dto/order_admin.go b/backend/app/http/tenant/dto/order_admin.go index 69c75bf..dddde31 100644 --- a/backend/app/http/tenant/dto/order_admin.go +++ b/backend/app/http/tenant/dto/order_admin.go @@ -1,6 +1,7 @@ package dto import ( + "strings" "time" "quyun/v2/app/requests" @@ -8,27 +9,65 @@ import ( "quyun/v2/pkg/consts" ) -// AdminOrderListFilter defines query filters for tenant-admin order listing. +// AdminOrderListFilter 租户管理员分页查询订单的过滤条件。 type AdminOrderListFilter struct { - // Pagination controls paging parameters (page/limit). + // Pagination 分页参数:page/limit(通用)。 requests.Pagination `json:",inline" query:",inline"` - // UserID filters orders by buyer user id. + + // UserID 下单用户ID(可选):按买家用户ID精确过滤。 UserID *int64 `json:"user_id,omitempty" query:"user_id"` - // ContentID filters orders by purchased content id (via order_items join). + + // Username 下单用户用户名关键字(可选):模糊匹配 users.username(like)。 + Username *string `json:"username,omitempty" query:"username"` + + // ContentID 内容ID(可选):通过 order_items 关联过滤。 ContentID *int64 `json:"content_id,omitempty" query:"content_id"` - // Status filters orders by order status. + + // ContentTitle 内容标题关键字(可选):通过 order_items + contents 关联,模糊匹配 contents.title(like)。 + ContentTitle *string `json:"content_title,omitempty" query:"content_title"` + + // Type 订单类型(可选):content_purchase/topup 等。 + Type *consts.OrderType `json:"type,omitempty" query:"type"` + + // Status 订单状态(可选):created/paid/refunding/refunded/canceled/failed。 Status *consts.OrderStatus `json:"status,omitempty" query:"status"` - // PaidAtFrom filters orders by paid_at >= this time. + + // CreatedAtFrom 创建时间起(可选):created_at >= 该时间(用于按创建时间筛选)。 + CreatedAtFrom *time.Time `json:"created_at_from,omitempty" query:"created_at_from"` + + // CreatedAtTo 创建时间止(可选):created_at <= 该时间(用于按创建时间筛选)。 + CreatedAtTo *time.Time `json:"created_at_to,omitempty" query:"created_at_to"` + + // PaidAtFrom 支付时间起(可选):paid_at >= 该时间(用于按支付时间筛选)。 PaidAtFrom *time.Time `json:"paid_at_from,omitempty" query:"paid_at_from"` - // PaidAtTo filters orders by paid_at <= this time. + + // PaidAtTo 支付时间止(可选):paid_at <= 该时间(用于按支付时间筛选)。 PaidAtTo *time.Time `json:"paid_at_to,omitempty" query:"paid_at_to"` - // AmountPaidMin filters orders by amount_paid >= this amount (cents). + + // AmountPaidMin 实付金额下限(可选):amount_paid >= 该值(单位分)。 AmountPaidMin *int64 `json:"amount_paid_min,omitempty" query:"amount_paid_min"` - // AmountPaidMax filters orders by amount_paid <= this amount (cents). + + // AmountPaidMax 实付金额上限(可选):amount_paid <= 该值(单位分)。 AmountPaidMax *int64 `json:"amount_paid_max,omitempty" query:"amount_paid_max"` } -// AdminOrderRefundForm defines payload for tenant-admin to refund an order. +// UsernameTrimmed 对 username 做统一处理,避免空白与大小写差异导致查询不一致。 +func (f *AdminOrderListFilter) UsernameTrimmed() string { + if f == nil || f.Username == nil { + return "" + } + return strings.TrimSpace(*f.Username) +} + +// ContentTitleTrimmed 对 content_title 做统一处理,避免空白与大小写差异导致查询不一致。 +func (f *AdminOrderListFilter) ContentTitleTrimmed() string { + if f == nil || f.ContentTitle == nil { + return "" + } + return strings.TrimSpace(*f.ContentTitle) +} + +// AdminOrderRefundForm 租户管理员退款的请求参数。 type AdminOrderRefundForm struct { // Force indicates bypassing the default refund window check (paid_at + 24h). // 强制退款:true 表示绕过默认退款时间窗限制(需审计)。 @@ -41,7 +80,7 @@ type AdminOrderRefundForm struct { IdempotencyKey string `json:"idempotency_key,omitempty"` } -// AdminOrderDetail returns a tenant-admin order detail payload. +// AdminOrderDetail 租户管理员订单详情返回结构。 type AdminOrderDetail struct { // Order is the order with items preloaded. Order *models.Order `json:"order,omitempty"` diff --git a/backend/app/http/tenant/order_admin.go b/backend/app/http/tenant/order_admin.go index 52b9fe4..9ef8c4c 100644 --- a/backend/app/http/tenant/order_admin.go +++ b/backend/app/http/tenant/order_admin.go @@ -41,10 +41,23 @@ func (*orderAdmin) adminOrderList( if err := requireTenantAdmin(tenantUser); err != nil { return nil, err } + if filter == nil { + filter = &dto.AdminOrderListFilter{} + } log.WithFields(log.Fields{ - "tenant_id": tenant.ID, - "user_id": tenantUser.UserID, + "tenant_id": tenant.ID, + "user_id": tenantUser.UserID, + "query_user_id": filter.UserID, + "username": filter.UsernameTrimmed(), + "content_id": filter.ContentID, + "content_title": filter.ContentTitleTrimmed(), + "type": filter.Type, + "status": filter.Status, + "created_at_from": filter.CreatedAtFrom, + "created_at_to": filter.CreatedAtTo, + "paid_at_from": filter.PaidAtFrom, + "paid_at_to": filter.PaidAtTo, }).Info("tenant.admin.orders.list") return services.Order.AdminOrderPage(ctx, tenant.ID, filter) diff --git a/backend/app/services/order.go b/backend/app/services/order.go index 82e5209..97f7558 100644 --- a/backend/app/services/order.go +++ b/backend/app/services/order.go @@ -10,6 +10,7 @@ import ( "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" @@ -350,10 +351,19 @@ func (s *order) AdminOrderPage( } logrus.WithFields(logrus.Fields{ - "tenant_id": tenantID, - "user_id": lo.FromPtr(filter.UserID), - "status": lo.FromPtr(filter.Status), - "content_id": lo.FromPtr(filter.ContentID), + "tenant_id": tenantID, + "user_id": lo.FromPtr(filter.UserID), + "username": filter.UsernameTrimmed(), + "content_id": lo.FromPtr(filter.ContentID), + "content_title": filter.ContentTitleTrimmed(), + "type": lo.FromPtr(filter.Type), + "status": lo.FromPtr(filter.Status), + "created_at_from": filter.CreatedAtFrom, + "created_at_to": filter.CreatedAtTo, + "paid_at_from": filter.PaidAtFrom, + "paid_at_to": filter.PaidAtTo, + "amount_paid_min": filter.AmountPaidMin, + "amount_paid_max": filter.AmountPaidMax, }).Info("services.order.admin.page") filter.Pagination.Format() @@ -365,9 +375,18 @@ func (s *order) AdminOrderPage( if filter.UserID != nil { conds = append(conds, tbl.UserID.Eq(*filter.UserID)) } + if filter.Type != nil { + conds = append(conds, tbl.Type.Eq(*filter.Type)) + } if filter.Status != nil { conds = append(conds, tbl.Status.Eq(*filter.Status)) } + if filter.CreatedAtFrom != nil { + conds = append(conds, tbl.CreatedAt.Gte(*filter.CreatedAtFrom)) + } + if filter.CreatedAtTo != nil { + conds = append(conds, tbl.CreatedAt.Lte(*filter.CreatedAtTo)) + } if filter.PaidAtFrom != nil { conds = append(conds, tbl.PaidAt.Gte(*filter.PaidAtFrom)) } @@ -380,10 +399,32 @@ func (s *order) AdminOrderPage( if filter.AmountPaidMax != nil { conds = append(conds, tbl.AmountPaid.Lte(*filter.AmountPaidMax)) } - if filter.ContentID != nil && *filter.ContentID > 0 { + + // 用户关键字:按 users.username 模糊匹配。 + // 关键点:orders.user_id 与 users.id 一对一,不会导致重复行,无需 group by。 + if username := filter.UsernameTrimmed(); username != "" { + uTbl, _ := models.UserQuery.QueryContext(ctx) + query = query.LeftJoin(uTbl, uTbl.ID.EqCol(tbl.UserID)) + conds = append(conds, uTbl.Username.Like(database.WrapLike(username))) + } + + // 内容过滤:通过 order_items(以及 contents)关联查询。 + // 关键点:orders 与 order_items 一对多,join 后必须 group by orders.id 以避免同一订单重复返回。 + needItemJoin := (filter.ContentID != nil && *filter.ContentID > 0) || filter.ContentTitleTrimmed() != "" + if needItemJoin { oiTbl, _ := models.OrderItemQuery.QueryContext(ctx) query = query.LeftJoin(oiTbl, oiTbl.OrderID.EqCol(tbl.ID)) - conds = append(conds, oiTbl.ContentID.Eq(*filter.ContentID)) + + if filter.ContentID != nil && *filter.ContentID > 0 { + conds = append(conds, oiTbl.ContentID.Eq(*filter.ContentID)) + } + + if title := filter.ContentTitleTrimmed(); title != "" { + cTbl, _ := models.ContentQuery.QueryContext(ctx) + query = query.LeftJoin(cTbl, cTbl.ID.EqCol(oiTbl.ContentID)) + conds = append(conds, cTbl.Title.Like(database.WrapLike(title))) + } + query = query.Group(tbl.ID) } @@ -650,7 +691,9 @@ func (s *order) PurchaseContent(ctx context.Context, params *PurchaseContentPara return err } var access models.ContentAccess - if err := tx.Where("tenant_id = ? AND user_id = ? AND content_id = ?", params.TenantID, params.UserID, params.ContentID).First(&access).Error; err != nil { + if err := tx.Table(models.TableNameContentAccess). + Where("tenant_id = ? AND user_id = ? AND content_id = ?", params.TenantID, params.UserID, params.ContentID). + First(&access).Error; err != nil { return err } out.AmountPaid = 0 @@ -762,7 +805,9 @@ func (s *order) PurchaseContent(ctx context.Context, params *PurchaseContentPara return err } var access models.ContentAccess - if err := tx.Where("tenant_id = ? AND user_id = ? AND content_id = ?", params.TenantID, params.UserID, params.ContentID).First(&access).Error; err != nil { + if err := tx.Table(models.TableNameContentAccess). + Where("tenant_id = ? AND user_id = ? AND content_id = ?", params.TenantID, params.UserID, params.ContentID). + First(&access).Error; err != nil { return err } out.Order = orderModel @@ -829,12 +874,18 @@ func (s *order) PurchaseContent(ctx context.Context, params *PurchaseContentPara }).Error; err != nil { return err } + // 关键点:上面是 DB 更新;这里同步更新内存对象,避免返回给调用方的状态仍为 created。 + orderModel.Status = consts.OrderStatusPaid + orderModel.PaidAt = now + orderModel.UpdatedAt = now if err := s.grantAccess(ctx, tx, params.TenantID, params.UserID, params.ContentID, orderModel.ID, now); err != nil { return err } var access models.ContentAccess - if err := tx.Where("tenant_id = ? AND user_id = ? AND content_id = ?", params.TenantID, params.UserID, params.ContentID).First(&access).Error; err != nil { + if err := tx.Table(models.TableNameContentAccess). + Where("tenant_id = ? AND user_id = ? AND content_id = ?", params.TenantID, params.UserID, params.ContentID). + First(&access).Error; err != nil { return err } @@ -993,7 +1044,9 @@ func (s *order) PurchaseContent(ctx context.Context, params *PurchaseContentPara return err } var access models.ContentAccess - if err := tx.Where("tenant_id = ? AND user_id = ? AND content_id = ?", params.TenantID, params.UserID, params.ContentID).First(&access).Error; err != nil { + if err := tx.Table(models.TableNameContentAccess). + Where("tenant_id = ? AND user_id = ? AND content_id = ?", params.TenantID, params.UserID, params.ContentID). + First(&access).Error; err != nil { return err } out.Order = orderModel @@ -1053,13 +1106,19 @@ func (s *order) PurchaseContent(ctx context.Context, params *PurchaseContentPara }).Error; err != nil { return err } + // 关键点:上面是 DB 更新;这里同步更新内存对象,避免返回给调用方的状态仍为 created。 + orderModel.Status = consts.OrderStatusPaid + orderModel.PaidAt = now + orderModel.UpdatedAt = now if err := s.grantAccess(ctx, tx, params.TenantID, params.UserID, params.ContentID, orderModel.ID, now); err != nil { return err } var access models.ContentAccess - if err := tx.Where("tenant_id = ? AND user_id = ? AND content_id = ?", params.TenantID, params.UserID, params.ContentID).First(&access).Error; err != nil { + if err := tx.Table(models.TableNameContentAccess). + Where("tenant_id = ? AND user_id = ? AND content_id = ?", params.TenantID, params.UserID, params.ContentID). + First(&access).Error; err != nil { return err } diff --git a/backend/app/services/order_test.go b/backend/app/services/order_test.go index b6cb749..1a1cce1 100644 --- a/backend/app/services/order_test.go +++ b/backend/app/services/order_test.go @@ -397,6 +397,238 @@ func (s *OrderTestSuite) Test_AdminOrderPage() { So(pager.Total, ShouldEqual, 1) }) + Convey("按 username 关键字过滤", func() { + s.truncate(ctx, models.TableNameOrderItem, models.TableNameOrder, models.TableNameUser) + + u1 := &models.User{ + Username: "alice", + Password: "x", + Roles: types.NewArray([]consts.Role{consts.RoleUser}), + Status: consts.UserStatusVerified, + Metas: types.JSON([]byte("{}")), + CreatedAt: now, + UpdatedAt: now, + } + So(u1.Create(ctx), ShouldBeNil) + u2 := &models.User{ + Username: "bob", + Password: "x", + Roles: types.NewArray([]consts.Role{consts.RoleUser}), + Status: consts.UserStatusVerified, + Metas: types.JSON([]byte("{}")), + CreatedAt: now, + UpdatedAt: now, + } + So(u2.Create(ctx), ShouldBeNil) + + o1 := &models.Order{ + TenantID: tenantID, + UserID: u1.ID, + 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) + o2 := &models.Order{ + TenantID: tenantID, + UserID: u2.ID, + 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) + + username := "ali" + pager, err := Order.AdminOrderPage(ctx, tenantID, &dto.AdminOrderListFilter{ + Username: &username, + }) + So(err, ShouldBeNil) + So(pager.Total, ShouldEqual, 1) + items, ok := pager.Items.([]*models.Order) + So(ok, ShouldBeTrue) + So(items[0].UserID, ShouldEqual, u1.ID) + }) + + Convey("按 content_title 关键字过滤", func() { + s.truncate(ctx, models.TableNameOrderItem, models.TableNameOrder, models.TableNameContent) + + c1 := &models.Content{ + TenantID: tenantID, + UserID: 1, + Title: "Go 教程", + Description: "desc", + Status: consts.ContentStatusPublished, + Visibility: consts.ContentVisibilityTenantOnly, + PreviewSeconds: consts.DefaultContentPreviewSeconds, + PreviewDownloadable: false, + PublishedAt: now, + CreatedAt: now, + UpdatedAt: now, + } + So(c1.Create(ctx), ShouldBeNil) + c2 := &models.Content{ + TenantID: tenantID, + UserID: 1, + Title: "Rust 教程", + Description: "desc", + Status: consts.ContentStatusPublished, + Visibility: consts.ContentVisibilityTenantOnly, + PreviewSeconds: consts.DefaultContentPreviewSeconds, + PreviewDownloadable: false, + PublishedAt: now, + CreatedAt: now, + UpdatedAt: now, + } + So(c2.Create(ctx), ShouldBeNil) + + 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: c1.ID, + ContentUserID: 1, + AmountPaid: 100, + Snapshot: types.JSON([]byte("{}")), + CreatedAt: now, + UpdatedAt: now, + }).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) + So((&models.OrderItem{ + TenantID: tenantID, + UserID: 3, + OrderID: o2.ID, + ContentID: c2.ID, + ContentUserID: 1, + AmountPaid: 100, + Snapshot: types.JSON([]byte("{}")), + CreatedAt: now, + UpdatedAt: now, + }).Create(ctx), ShouldBeNil) + + title := "Go" + pager, err := Order.AdminOrderPage(ctx, tenantID, &dto.AdminOrderListFilter{ + ContentTitle: &title, + }) + So(err, ShouldBeNil) + So(pager.Total, ShouldEqual, 1) + }) + + Convey("按 created_at 时间窗过滤", 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.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: 200, + Snapshot: types.JSON([]byte("{}")), + PaidAt: now, + CreatedAt: now, + UpdatedAt: now, + } + So(o2.Create(ctx), ShouldBeNil) + + from := now.Add(-10 * time.Minute) + to := now.Add(10 * time.Minute) + pager, err := Order.AdminOrderPage(ctx, tenantID, &dto.AdminOrderListFilter{ + CreatedAtFrom: &from, + CreatedAtTo: &to, + }) + So(err, ShouldBeNil) + So(pager.Total, ShouldEqual, 1) + }) + + Convey("按 type 过滤", func() { + s.truncate(ctx, models.TableNameOrderItem, models.TableNameOrder) + + o1 := &models.Order{ + TenantID: tenantID, + UserID: 2, + Type: consts.OrderTypeTopup, + Status: consts.OrderStatusPaid, + Currency: consts.CurrencyCNY, + AmountPaid: 100, + Snapshot: types.JSON([]byte("{}")), + PaidAt: now, + CreatedAt: now, + UpdatedAt: now, + } + So(o1.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) + + typ := consts.OrderTypeTopup + pager, err := Order.AdminOrderPage(ctx, tenantID, &dto.AdminOrderListFilter{ + Type: &typ, + }) + So(err, ShouldBeNil) + So(pager.Total, ShouldEqual, 1) + }) + Convey("组合筛选:user_id + status + amount_paid 区间 + content_id", func() { s.truncate(ctx, models.TableNameOrderItem, models.TableNameOrder) diff --git a/backend/database/models/content_access.go b/backend/database/models/content_access.go new file mode 100644 index 0000000..6416c59 --- /dev/null +++ b/backend/database/models/content_access.go @@ -0,0 +1,7 @@ +package models + +// TableName 覆盖 GORM 对 ContentAccess 的默认表名推导(content_accesses), +// 保证与迁移中的实际表名 content_access 一致,避免查询/写入找不到表。 +func (ContentAccess) TableName() string { + return TableNameContentAccess +} diff --git a/backend/docs/docs.go b/backend/docs/docs.go index e6ae5a9..01da82d 100644 --- a/backend/docs/docs.go +++ b/backend/docs/docs.go @@ -961,22 +961,40 @@ const docTemplate = `{ }, { "type": "integer", - "description": "AmountPaidMax filters orders by amount_paid \u003c= this amount (cents).", + "description": "AmountPaidMax 实付金额上限(可选):amount_paid \u003c= 该值(单位分)。", "name": "amount_paid_max", "in": "query" }, { "type": "integer", - "description": "AmountPaidMin filters orders by amount_paid \u003e= this amount (cents).", + "description": "AmountPaidMin 实付金额下限(可选):amount_paid \u003e= 该值(单位分)。", "name": "amount_paid_min", "in": "query" }, { "type": "integer", - "description": "ContentID filters orders by purchased content id (via order_items join).", + "description": "ContentID 内容ID(可选):通过 order_items 关联过滤。", "name": "content_id", "in": "query" }, + { + "type": "string", + "description": "ContentTitle 内容标题关键字(可选):通过 order_items + contents 关联,模糊匹配 contents.title(like)。", + "name": "content_title", + "in": "query" + }, + { + "type": "string", + "description": "CreatedAtFrom 创建时间起(可选):created_at \u003e= 该时间(用于按创建时间筛选)。", + "name": "created_at_from", + "in": "query" + }, + { + "type": "string", + "description": "CreatedAtTo 创建时间止(可选):created_at \u003c= 该时间(用于按创建时间筛选)。", + "name": "created_at_to", + "in": "query" + }, { "type": "integer", "description": "Limit is page size; only values in {10,20,50,100} are accepted (otherwise defaults to 10).", @@ -991,13 +1009,13 @@ const docTemplate = `{ }, { "type": "string", - "description": "PaidAtFrom filters orders by paid_at \u003e= this time.", + "description": "PaidAtFrom 支付时间起(可选):paid_at \u003e= 该时间(用于按支付时间筛选)。", "name": "paid_at_from", "in": "query" }, { "type": "string", - "description": "PaidAtTo filters orders by paid_at \u003c= this time.", + "description": "PaidAtTo 支付时间止(可选):paid_at \u003c= 该时间(用于按支付时间筛选)。", "name": "paid_at_to", "in": "query" }, @@ -1019,15 +1037,35 @@ const docTemplate = `{ "OrderStatusCanceled", "OrderStatusFailed" ], - "description": "Status filters orders by order status.", + "description": "Status 订单状态(可选):created/paid/refunding/refunded/canceled/failed。", "name": "status", "in": "query" }, + { + "enum": [ + "content_purchase", + "topup" + ], + "type": "string", + "x-enum-varnames": [ + "OrderTypeContentPurchase", + "OrderTypeTopup" + ], + "description": "Type 订单类型(可选):content_purchase/topup 等。", + "name": "type", + "in": "query" + }, { "type": "integer", - "description": "UserID filters orders by buyer user id.", + "description": "UserID 下单用户ID(可选):按买家用户ID精确过滤。", "name": "user_id", "in": "query" + }, + { + "type": "string", + "description": "Username 下单用户用户名关键字(可选):模糊匹配 users.username(like)。", + "name": "username", + "in": "query" } ], "responses": { diff --git a/backend/docs/swagger.json b/backend/docs/swagger.json index 9e12eff..7b5413b 100644 --- a/backend/docs/swagger.json +++ b/backend/docs/swagger.json @@ -955,22 +955,40 @@ }, { "type": "integer", - "description": "AmountPaidMax filters orders by amount_paid \u003c= this amount (cents).", + "description": "AmountPaidMax 实付金额上限(可选):amount_paid \u003c= 该值(单位分)。", "name": "amount_paid_max", "in": "query" }, { "type": "integer", - "description": "AmountPaidMin filters orders by amount_paid \u003e= this amount (cents).", + "description": "AmountPaidMin 实付金额下限(可选):amount_paid \u003e= 该值(单位分)。", "name": "amount_paid_min", "in": "query" }, { "type": "integer", - "description": "ContentID filters orders by purchased content id (via order_items join).", + "description": "ContentID 内容ID(可选):通过 order_items 关联过滤。", "name": "content_id", "in": "query" }, + { + "type": "string", + "description": "ContentTitle 内容标题关键字(可选):通过 order_items + contents 关联,模糊匹配 contents.title(like)。", + "name": "content_title", + "in": "query" + }, + { + "type": "string", + "description": "CreatedAtFrom 创建时间起(可选):created_at \u003e= 该时间(用于按创建时间筛选)。", + "name": "created_at_from", + "in": "query" + }, + { + "type": "string", + "description": "CreatedAtTo 创建时间止(可选):created_at \u003c= 该时间(用于按创建时间筛选)。", + "name": "created_at_to", + "in": "query" + }, { "type": "integer", "description": "Limit is page size; only values in {10,20,50,100} are accepted (otherwise defaults to 10).", @@ -985,13 +1003,13 @@ }, { "type": "string", - "description": "PaidAtFrom filters orders by paid_at \u003e= this time.", + "description": "PaidAtFrom 支付时间起(可选):paid_at \u003e= 该时间(用于按支付时间筛选)。", "name": "paid_at_from", "in": "query" }, { "type": "string", - "description": "PaidAtTo filters orders by paid_at \u003c= this time.", + "description": "PaidAtTo 支付时间止(可选):paid_at \u003c= 该时间(用于按支付时间筛选)。", "name": "paid_at_to", "in": "query" }, @@ -1013,15 +1031,35 @@ "OrderStatusCanceled", "OrderStatusFailed" ], - "description": "Status filters orders by order status.", + "description": "Status 订单状态(可选):created/paid/refunding/refunded/canceled/failed。", "name": "status", "in": "query" }, + { + "enum": [ + "content_purchase", + "topup" + ], + "type": "string", + "x-enum-varnames": [ + "OrderTypeContentPurchase", + "OrderTypeTopup" + ], + "description": "Type 订单类型(可选):content_purchase/topup 等。", + "name": "type", + "in": "query" + }, { "type": "integer", - "description": "UserID filters orders by buyer user id.", + "description": "UserID 下单用户ID(可选):按买家用户ID精确过滤。", "name": "user_id", "in": "query" + }, + { + "type": "string", + "description": "Username 下单用户用户名关键字(可选):模糊匹配 users.username(like)。", + "name": "username", + "in": "query" } ], "responses": { diff --git a/backend/docs/swagger.yaml b/backend/docs/swagger.yaml index 8248d78..1242ac5 100644 --- a/backend/docs/swagger.yaml +++ b/backend/docs/swagger.yaml @@ -1767,19 +1767,30 @@ paths: name: tenantCode required: true type: string - - description: AmountPaidMax filters orders by amount_paid <= this amount (cents). + - description: AmountPaidMax 实付金额上限(可选):amount_paid <= 该值(单位分)。 in: query name: amount_paid_max type: integer - - description: AmountPaidMin filters orders by amount_paid >= this amount (cents). + - description: AmountPaidMin 实付金额下限(可选):amount_paid >= 该值(单位分)。 in: query name: amount_paid_min type: integer - - description: ContentID filters orders by purchased content id (via order_items - join). + - description: ContentID 内容ID(可选):通过 order_items 关联过滤。 in: query name: content_id type: integer + - description: ContentTitle 内容标题关键字(可选):通过 order_items + contents 关联,模糊匹配 contents.title(like)。 + in: query + name: content_title + type: string + - description: CreatedAtFrom 创建时间起(可选):created_at >= 该时间(用于按创建时间筛选)。 + in: query + name: created_at_from + type: string + - description: CreatedAtTo 创建时间止(可选):created_at <= 该时间(用于按创建时间筛选)。 + in: query + name: created_at_to + type: string - description: Limit is page size; only values in {10,20,50,100} are accepted (otherwise defaults to 10). in: query @@ -1789,15 +1800,15 @@ paths: in: query name: page type: integer - - description: PaidAtFrom filters orders by paid_at >= this time. + - description: PaidAtFrom 支付时间起(可选):paid_at >= 该时间(用于按支付时间筛选)。 in: query name: paid_at_from type: string - - description: PaidAtTo filters orders by paid_at <= this time. + - description: PaidAtTo 支付时间止(可选):paid_at <= 该时间(用于按支付时间筛选)。 in: query name: paid_at_to type: string - - description: Status filters orders by order status. + - description: Status 订单状态(可选):created/paid/refunding/refunded/canceled/failed。 enum: - created - paid @@ -1815,10 +1826,24 @@ paths: - OrderStatusRefunded - OrderStatusCanceled - OrderStatusFailed - - description: UserID filters orders by buyer user id. + - description: Type 订单类型(可选):content_purchase/topup 等。 + enum: + - content_purchase + - topup + in: query + name: type + type: string + x-enum-varnames: + - OrderTypeContentPurchase + - OrderTypeTopup + - description: UserID 下单用户ID(可选):按买家用户ID精确过滤。 in: query name: user_id type: integer + - description: Username 下单用户用户名关键字(可选):模糊匹配 users.username(like)。 + in: query + name: username + type: string produces: - application/json responses: diff --git a/backend/tests/tenant.http b/backend/tests/tenant.http index 5b4805c..0f81894 100644 --- a/backend/tests/tenant.http +++ b/backend/tests/tenant.http @@ -141,6 +141,11 @@ GET {{ host }}/t/{{ tenantCode }}/v1/admin/orders?page=1&limit=10&user_id=2&cont 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 +Content-Type: application/json +Authorization: Bearer {{ token }} + ### Tenant Admin - Order detail @orderID = 1 GET {{ host }}/t/{{ tenantCode }}/v1/admin/orders/{{ orderID }}