diff --git a/backend/app/http/super/dto/order_page.go b/backend/app/http/super/dto/order_page.go new file mode 100644 index 0000000..d159146 --- /dev/null +++ b/backend/app/http/super/dto/order_page.go @@ -0,0 +1,96 @@ +package dto + +import ( + "strings" + "time" + + "quyun/v2/app/requests" + "quyun/v2/pkg/consts" +) + +type OrderPageFilter struct { + requests.Pagination `json:",inline" query:",inline"` + requests.SortQueryFilter `json:",inline" query:",inline"` + + ID *int64 `json:"id,omitempty" query:"id"` + TenantID *int64 `json:"tenant_id,omitempty" query:"tenant_id"` + UserID *int64 `json:"user_id,omitempty" query:"user_id"` + + TenantCode *string `json:"tenant_code,omitempty" query:"tenant_code"` + TenantName *string `json:"tenant_name,omitempty" query:"tenant_name"` + Username *string `json:"username,omitempty" query:"username"` + + ContentID *int64 `json:"content_id,omitempty" query:"content_id"` + ContentTitle *string `json:"content_title,omitempty" query:"content_title"` + + Type *consts.OrderType `json:"type,omitempty" query:"type"` + Status *consts.OrderStatus `json:"status,omitempty" query:"status"` + + CreatedAtFrom *time.Time `json:"created_at_from,omitempty" query:"created_at_from"` + CreatedAtTo *time.Time `json:"created_at_to,omitempty" query:"created_at_to"` + PaidAtFrom *time.Time `json:"paid_at_from,omitempty" query:"paid_at_from"` + PaidAtTo *time.Time `json:"paid_at_to,omitempty" query:"paid_at_to"` + + AmountPaidMin *int64 `json:"amount_paid_min,omitempty" query:"amount_paid_min"` + AmountPaidMax *int64 `json:"amount_paid_max,omitempty" query:"amount_paid_max"` +} + +func (f *OrderPageFilter) TenantCodeTrimmed() string { + if f == nil || f.TenantCode == nil { + return "" + } + return strings.ToLower(strings.TrimSpace(*f.TenantCode)) +} + +func (f *OrderPageFilter) TenantNameTrimmed() string { + if f == nil || f.TenantName == nil { + return "" + } + return strings.TrimSpace(*f.TenantName) +} + +func (f *OrderPageFilter) UsernameTrimmed() string { + if f == nil || f.Username == nil { + return "" + } + return strings.TrimSpace(*f.Username) +} + +func (f *OrderPageFilter) ContentTitleTrimmed() string { + if f == nil || f.ContentTitle == nil { + return "" + } + return strings.TrimSpace(*f.ContentTitle) +} + +type OrderTenantLite struct { + ID int64 `json:"id"` + Code string `json:"code"` + Name string `json:"name"` +} + +type OrderBuyerLite struct { + ID int64 `json:"id"` + Username string `json:"username"` +} + +type SuperOrderItem struct { + ID int64 `json:"id"` + Tenant *OrderTenantLite `json:"tenant,omitempty"` + Buyer *OrderBuyerLite `json:"buyer,omitempty"` + + Type consts.OrderType `json:"type"` + Status consts.OrderStatus `json:"status"` + + StatusDescription string `json:"status_description,omitempty"` + Currency consts.Currency `json:"currency"` + + AmountOriginal int64 `json:"amount_original"` + AmountDiscount int64 `json:"amount_discount"` + AmountPaid int64 `json:"amount_paid"` + + PaidAt time.Time `json:"paid_at"` + RefundedAt time.Time `json:"refunded_at"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} diff --git a/backend/app/http/super/order.go b/backend/app/http/super/order.go index 2c3bd3b..14762c1 100644 --- a/backend/app/http/super/order.go +++ b/backend/app/http/super/order.go @@ -2,6 +2,7 @@ package super import ( "quyun/v2/app/http/super/dto" + "quyun/v2/app/requests" "quyun/v2/app/services" "github.com/gofiber/fiber/v3" @@ -10,6 +11,21 @@ import ( // @provider type order struct{} +// list +// +// @Summary 订单列表 +// @Tags Super +// @Accept json +// @Produce json +// @Param filter query dto.OrderPageFilter true "Filter" +// @Success 200 {object} requests.Pager{items=dto.SuperOrderItem} +// +// @Router /super/v1/orders [get] +// @Bind filter query +func (*order) list(ctx fiber.Ctx, filter *dto.OrderPageFilter) (*requests.Pager, error) { + return services.Order.SuperOrderPage(ctx, filter) +} + // statistics // // @Summary 订单统计信息 diff --git a/backend/app/http/super/routes.gen.go b/backend/app/http/super/routes.gen.go index c76ae20..cdb961e 100644 --- a/backend/app/http/super/routes.gen.go +++ b/backend/app/http/super/routes.gen.go @@ -56,6 +56,11 @@ func (r *Routes) Register(router fiber.Router) { Body[dto.LoginForm]("form"), )) // Register routes for controller: order + r.log.Debugf("Registering route: Get /super/v1/orders -> order.list") + router.Get("/super/v1/orders"[len(r.Path()):], DataFunc1( + r.order.list, + Query[dto.OrderPageFilter]("filter"), + )) r.log.Debugf("Registering route: Get /super/v1/orders/statistics -> order.statistics") router.Get("/super/v1/orders/statistics"[len(r.Path()):], DataFunc0( r.order.statistics, diff --git a/backend/app/services/order.go b/backend/app/services/order.go index 2131f48..c126eca 100644 --- a/backend/app/services/order.go +++ b/backend/app/services/order.go @@ -199,6 +199,224 @@ func (s *order) AdminOrderExportCSV( }, nil } +// SuperOrderPage 平台侧分页查询订单(跨租户)。 +func (s *order) SuperOrderPage(ctx context.Context, filter *superdto.OrderPageFilter) (*requests.Pager, error) { + if filter == nil { + filter = &superdto.OrderPageFilter{} + } + + filter.Pagination.Format() + + tbl, query := models.OrderQuery.QueryContext(ctx) + conds := []gen.Condition{} + + if filter.ID != nil && *filter.ID > 0 { + conds = append(conds, tbl.ID.Eq(*filter.ID)) + } + if filter.TenantID != nil && *filter.TenantID > 0 { + conds = append(conds, tbl.TenantID.Eq(*filter.TenantID)) + } + if filter.UserID != nil && *filter.UserID > 0 { + conds = append(conds, tbl.UserID.Eq(*filter.UserID)) + } + if filter.Type != nil && *filter.Type != "" { + conds = append(conds, tbl.Type.Eq(*filter.Type)) + } + if filter.Status != nil && *filter.Status != "" { + 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)) + } + 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 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))) + } + + // 租户 code/name 关键字。 + tenantCode := filter.TenantCodeTrimmed() + tenantName := filter.TenantNameTrimmed() + if tenantCode != "" || tenantName != "" { + tTbl, _ := models.TenantQuery.QueryContext(ctx) + query = query.LeftJoin(tTbl, tTbl.ID.EqCol(tbl.TenantID)) + if tenantCode != "" { + conds = append(conds, tTbl.Code.Like(database.WrapLike(tenantCode))) + } + if tenantName != "" { + conds = append(conds, tTbl.Name.Like(database.WrapLike(tenantName))) + } + } + + // 内容过滤(orders 与 order_items 一对多,需要 group by)。 + 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)) + + 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) + } + + // 排序白名单:避免把任意字符串拼进 SQL 导致注入或慢查询。 + orderBys := make([]field.Expr, 0, 6) + allowedAsc := map[string]field.Expr{ + "id": tbl.ID.Asc(), + "tenant_id": tbl.TenantID.Asc(), + "user_id": tbl.UserID.Asc(), + "status": tbl.Status.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(), + "tenant_id": tbl.TenantID.Desc(), + "user_id": tbl.UserID.Desc(), + "status": tbl.Status.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) + } + } + if len(orderBys) == 0 { + orderBys = append(orderBys, tbl.ID.Desc()) + } else { + orderBys = append(orderBys, tbl.ID.Desc()) + } + + orders, total, err := query.Where(conds...).Order(orderBys...).FindByPage(int(filter.Offset()), int(filter.Limit)) + if err != nil { + return nil, err + } + + tenantIDs := make([]int64, 0, len(orders)) + userIDs := make([]int64, 0, len(orders)) + for _, o := range orders { + if o == nil { + continue + } + if o.TenantID > 0 { + tenantIDs = append(tenantIDs, o.TenantID) + } + if o.UserID > 0 { + userIDs = append(userIDs, o.UserID) + } + } + tenantIDs = lo.Uniq(tenantIDs) + userIDs = lo.Uniq(userIDs) + + tenantMap := make(map[int64]*models.Tenant, len(tenantIDs)) + if len(tenantIDs) > 0 { + tTbl, tQuery := models.TenantQuery.QueryContext(ctx) + tenants, err := tQuery.Where(tTbl.ID.In(tenantIDs...)).Find() + if err != nil { + return nil, err + } + for _, te := range tenants { + if te == nil { + continue + } + tenantMap[te.ID] = te + } + } + + userMap := make(map[int64]*models.User, len(userIDs)) + if len(userIDs) > 0 { + uTbl, uQuery := models.UserQuery.QueryContext(ctx) + users, err := uQuery.Where(uTbl.ID.In(userIDs...)).Find() + if err != nil { + return nil, err + } + for _, u := range users { + if u == nil { + continue + } + userMap[u.ID] = u + } + } + + items := lo.Map(orders, func(o *models.Order, _ int) *superdto.SuperOrderItem { + if o == nil { + return &superdto.SuperOrderItem{} + } + + var tenantLite *superdto.OrderTenantLite + if te := tenantMap[o.TenantID]; te != nil { + tenantLite = &superdto.OrderTenantLite{ID: te.ID, Code: te.Code, Name: te.Name} + } + + var buyerLite *superdto.OrderBuyerLite + if u := userMap[o.UserID]; u != nil { + buyerLite = &superdto.OrderBuyerLite{ID: u.ID, Username: u.Username} + } + + return &superdto.SuperOrderItem{ + ID: o.ID, + Tenant: tenantLite, + Buyer: buyerLite, + Type: o.Type, + Status: o.Status, + StatusDescription: o.Status.Description(), + Currency: o.Currency, + AmountOriginal: o.AmountOriginal, + AmountDiscount: o.AmountDiscount, + AmountPaid: o.AmountPaid, + PaidAt: o.PaidAt, + RefundedAt: o.RefundedAt, + CreatedAt: o.CreatedAt, + UpdatedAt: o.UpdatedAt, + } + }) + + return &requests.Pager{ + Pagination: filter.Pagination, + Total: total, + Items: items, + }, nil +} + // PurchaseContentParams 定义“租户内使用余额购买内容”的入参。 type PurchaseContentParams struct { // TenantID 租户 ID(多租户隔离范围)。 diff --git a/frontend/superadmin/dist/index.html b/frontend/superadmin/dist/index.html index b26c0ab..187cedc 100644 --- a/frontend/superadmin/dist/index.html +++ b/frontend/superadmin/dist/index.html @@ -7,7 +7,7 @@