From fcbc6bd394b6ff805969202045fb804296e5987f Mon Sep 17 00:00:00 2001 From: Rogee Date: Wed, 24 Dec 2025 09:30:12 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=B7=BB=E5=8A=A0=E8=AE=A2=E5=8D=95?= =?UTF-8?q?=E7=AE=A1=E7=90=86=E5=8A=9F=E8=83=BD=EF=BC=8C=E6=94=AF=E6=8C=81?= =?UTF-8?q?=E8=B7=A8=E7=A7=9F=E6=88=B7=E5=88=86=E9=A1=B5=E6=9F=A5=E8=AF=A2?= =?UTF-8?q?=EF=BC=8C=E4=BC=98=E5=8C=96=E8=AE=A2=E5=8D=95=E5=88=97=E8=A1=A8?= =?UTF-8?q?=E5=B1=95=E7=A4=BA=E5=8F=8A=E6=9F=A5=E8=AF=A2=E6=9D=A1=E4=BB=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/app/http/super/dto/order_page.go | 96 ++++++ backend/app/http/super/order.go | 16 + backend/app/http/super/routes.gen.go | 5 + backend/app/services/order.go | 218 +++++++++++++ frontend/superadmin/dist/index.html | 2 +- frontend/superadmin/src/layout/AppMenu.vue | 3 +- frontend/superadmin/src/router/index.js | 5 + .../superadmin/src/service/OrderService.js | 69 +++- .../src/views/superadmin/Orders.vue | 297 ++++++++++++++++++ 9 files changed, 708 insertions(+), 3 deletions(-) create mode 100644 backend/app/http/super/dto/order_page.go create mode 100644 frontend/superadmin/src/views/superadmin/Orders.vue 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 @@ Sakai Vue - + diff --git a/frontend/superadmin/src/layout/AppMenu.vue b/frontend/superadmin/src/layout/AppMenu.vue index a06a141..2f1d83c 100644 --- a/frontend/superadmin/src/layout/AppMenu.vue +++ b/frontend/superadmin/src/layout/AppMenu.vue @@ -12,7 +12,8 @@ const model = ref([ label: 'Super Admin', items: [ { label: 'Tenants', icon: 'pi pi-fw pi-building', to: '/superadmin/tenants' }, - { label: 'Users', icon: 'pi pi-fw pi-users', to: '/superadmin/users' } + { label: 'Users', icon: 'pi pi-fw pi-users', to: '/superadmin/users' }, + { label: 'Orders', icon: 'pi pi-fw pi-shopping-cart', to: '/superadmin/orders' } ] } ]); diff --git a/frontend/superadmin/src/router/index.js b/frontend/superadmin/src/router/index.js index 6e2bd2f..578e8a6 100644 --- a/frontend/superadmin/src/router/index.js +++ b/frontend/superadmin/src/router/index.js @@ -123,6 +123,11 @@ const router = createRouter({ path: '/superadmin/users', name: 'superadmin-users', component: () => import('@/views/superadmin/Users.vue') + }, + { + path: '/superadmin/orders', + name: 'superadmin-orders', + component: () => import('@/views/superadmin/Orders.vue') } ] }, diff --git a/frontend/superadmin/src/service/OrderService.js b/frontend/superadmin/src/service/OrderService.js index 215a334..93d607c 100644 --- a/frontend/superadmin/src/service/OrderService.js +++ b/frontend/superadmin/src/service/OrderService.js @@ -1,8 +1,75 @@ import { requestJson } from './apiClient'; +function normalizeItems(items) { + if (Array.isArray(items)) return items; + if (items && typeof items === 'object') return [items]; + return []; +} + export const OrderService = { + async listOrders({ + page, + limit, + id, + tenant_id, + tenant_code, + tenant_name, + user_id, + username, + content_id, + content_title, + type, + status, + created_at_from, + created_at_to, + paid_at_from, + paid_at_to, + amount_paid_min, + amount_paid_max, + sortField, + sortOrder + } = {}) { + const iso = (d) => { + if (!d) return undefined; + const date = d instanceof Date ? d : new Date(d); + if (Number.isNaN(date.getTime())) return undefined; + return date.toISOString(); + }; + + const query = { + page, + limit, + id, + tenant_id, + tenant_code, + tenant_name, + user_id, + username, + content_id, + content_title, + type, + status, + created_at_from: iso(created_at_from), + created_at_to: iso(created_at_to), + paid_at_from: iso(paid_at_from), + paid_at_to: iso(paid_at_to), + amount_paid_min, + amount_paid_max + }; + if (sortField && sortOrder) { + if (sortOrder === 1) query.asc = sortField; + if (sortOrder === -1) query.desc = sortField; + } + + const data = await requestJson('/super/v1/orders', { query }); + return { + page: data?.page ?? page ?? 1, + limit: data?.limit ?? limit ?? 10, + total: data?.total ?? 0, + items: normalizeItems(data?.items) + }; + }, async getOrderStatistics() { return requestJson('/super/v1/orders/statistics'); } }; - diff --git a/frontend/superadmin/src/views/superadmin/Orders.vue b/frontend/superadmin/src/views/superadmin/Orders.vue new file mode 100644 index 0000000..2950af3 --- /dev/null +++ b/frontend/superadmin/src/views/superadmin/Orders.vue @@ -0,0 +1,297 @@ + + + +