From 235a216b0c3410377dbe583ddb59ff31a558958d Mon Sep 17 00:00:00 2001 From: Rogee Date: Thu, 15 Jan 2026 09:35:16 +0800 Subject: [PATCH] feat: wire superadmin p1 data --- backend/app/http/super/v1/coupons.go | 28 + backend/app/http/super/v1/creators.go | 28 + backend/app/http/super/v1/dto/super_coupon.go | 75 +++ backend/app/http/super/v1/dto/super_report.go | 27 + .../app/http/super/v1/dto/super_withdrawal.go | 7 + backend/app/http/super/v1/provider.gen.go | 36 ++ backend/app/http/super/v1/reports.go | 41 ++ backend/app/http/super/v1/routes.gen.go | 54 +- backend/app/http/super/v1/withdrawals.go | 63 ++ backend/app/services/super.go | 551 +++++++++++++++++- backend/docs/docs.go | 421 +++++++++++++ backend/docs/swagger.json | 421 +++++++++++++ backend/docs/swagger.yaml | 275 +++++++++ .../superadmin/src/service/CouponService.js | 44 ++ .../superadmin/src/service/CreatorService.js | 42 ++ .../superadmin/src/service/FinanceService.js | 59 ++ .../superadmin/src/service/ReportService.js | 39 ++ .../src/views/superadmin/Coupons.vue | 248 +++++++- .../src/views/superadmin/Creators.vue | 273 ++++++++- .../src/views/superadmin/Finance.vue | 338 ++++++++++- .../src/views/superadmin/Reports.vue | 146 ++++- 21 files changed, 3188 insertions(+), 28 deletions(-) create mode 100644 backend/app/http/super/v1/coupons.go create mode 100644 backend/app/http/super/v1/creators.go create mode 100644 backend/app/http/super/v1/dto/super_coupon.go create mode 100644 backend/app/http/super/v1/dto/super_report.go create mode 100644 backend/app/http/super/v1/dto/super_withdrawal.go create mode 100644 backend/app/http/super/v1/reports.go create mode 100644 backend/app/http/super/v1/withdrawals.go create mode 100644 frontend/superadmin/src/service/CouponService.js create mode 100644 frontend/superadmin/src/service/CreatorService.js create mode 100644 frontend/superadmin/src/service/FinanceService.js create mode 100644 frontend/superadmin/src/service/ReportService.js diff --git a/backend/app/http/super/v1/coupons.go b/backend/app/http/super/v1/coupons.go new file mode 100644 index 0000000..9385515 --- /dev/null +++ b/backend/app/http/super/v1/coupons.go @@ -0,0 +1,28 @@ +package v1 + +import ( + dto "quyun/v2/app/http/super/v1/dto" + "quyun/v2/app/requests" + "quyun/v2/app/services" + + "github.com/gofiber/fiber/v3" +) + +// @provider +type coupons struct{} + +// List coupons +// +// @Router /super/v1/coupons [get] +// @Summary List coupons +// @Description List coupon templates across tenants +// @Tags Coupon +// @Accept json +// @Produce json +// @Param page query int false "Page number" +// @Param limit query int false "Page size" +// @Success 200 {object} requests.Pager{items=[]dto.SuperCouponItem} +// @Bind filter query +func (c *coupons) List(ctx fiber.Ctx, filter *dto.SuperCouponListFilter) (*requests.Pager, error) { + return services.Super.ListCoupons(ctx, filter) +} diff --git a/backend/app/http/super/v1/creators.go b/backend/app/http/super/v1/creators.go new file mode 100644 index 0000000..9ca697b --- /dev/null +++ b/backend/app/http/super/v1/creators.go @@ -0,0 +1,28 @@ +package v1 + +import ( + dto "quyun/v2/app/http/super/v1/dto" + "quyun/v2/app/requests" + "quyun/v2/app/services" + + "github.com/gofiber/fiber/v3" +) + +// @provider +type creators struct{} + +// List creators +// +// @Router /super/v1/creators [get] +// @Summary List creators +// @Description List creator tenants (channels) across the platform +// @Tags Creator +// @Accept json +// @Produce json +// @Param page query int false "Page number" +// @Param limit query int false "Page size" +// @Success 200 {object} requests.Pager{items=[]dto.TenantItem} +// @Bind filter query +func (c *creators) List(ctx fiber.Ctx, filter *dto.TenantListFilter) (*requests.Pager, error) { + return services.Super.ListTenants(ctx, filter) +} diff --git a/backend/app/http/super/v1/dto/super_coupon.go b/backend/app/http/super/v1/dto/super_coupon.go new file mode 100644 index 0000000..88f3fc6 --- /dev/null +++ b/backend/app/http/super/v1/dto/super_coupon.go @@ -0,0 +1,75 @@ +package dto + +import ( + "quyun/v2/app/requests" + "quyun/v2/pkg/consts" +) + +// SuperCouponListFilter 超管优惠券列表过滤条件。 +type SuperCouponListFilter struct { + requests.Pagination + // ID 优惠券ID,精确匹配。 + ID *int64 `query:"id"` + // TenantID 租户ID,精确匹配。 + TenantID *int64 `query:"tenant_id"` + // TenantCode 租户编码,模糊匹配。 + TenantCode *string `query:"tenant_code"` + // TenantName 租户名称,模糊匹配。 + TenantName *string `query:"tenant_name"` + // Keyword 标题或描述关键词,模糊匹配。 + Keyword *string `query:"keyword"` + // Type 优惠券类型过滤(fix_amount/discount)。 + Type *string `query:"type"` + // Status 状态过滤(active/expired/upcoming)。 + Status *string `query:"status"` + // CreatedAtFrom 创建时间起始(RFC3339)。 + CreatedAtFrom *string `query:"created_at_from"` + // CreatedAtTo 创建时间结束(RFC3339)。 + CreatedAtTo *string `query:"created_at_to"` + // Asc 升序字段(id/created_at/start_at/end_at)。 + Asc *string `query:"asc"` + // Desc 降序字段(id/created_at/start_at/end_at)。 + Desc *string `query:"desc"` +} + +// SuperCouponItem 超管优惠券列表项。 +type SuperCouponItem struct { + // ID 优惠券ID。 + ID int64 `json:"id"` + // TenantID 租户ID。 + TenantID int64 `json:"tenant_id"` + // TenantCode 租户编码。 + TenantCode string `json:"tenant_code"` + // TenantName 租户名称。 + TenantName string `json:"tenant_name"` + // Title 优惠券标题。 + Title string `json:"title"` + // Description 优惠券描述。 + Description string `json:"description"` + // Type 优惠券类型。 + Type consts.CouponType `json:"type"` + // TypeDescription 类型描述(用于展示)。 + TypeDescription string `json:"type_description"` + // Value 优惠券面额/折扣值。 + Value int64 `json:"value"` + // MinOrderAmount 最低订单金额门槛。 + MinOrderAmount int64 `json:"min_order_amount"` + // MaxDiscount 最大折扣金额(折扣券)。 + MaxDiscount int64 `json:"max_discount"` + // TotalQuantity 总发行数量(0 表示不限量)。 + TotalQuantity int32 `json:"total_quantity"` + // UsedQuantity 已使用数量。 + UsedQuantity int32 `json:"used_quantity"` + // Status 状态(active/expired/upcoming)。 + Status string `json:"status"` + // StatusDescription 状态描述(用于展示)。 + StatusDescription string `json:"status_description"` + // StartAt 生效时间(RFC3339)。 + StartAt string `json:"start_at"` + // EndAt 结束时间(RFC3339)。 + EndAt string `json:"end_at"` + // CreatedAt 创建时间(RFC3339)。 + CreatedAt string `json:"created_at"` + // UpdatedAt 更新时间(RFC3339)。 + UpdatedAt string `json:"updated_at"` +} diff --git a/backend/app/http/super/v1/dto/super_report.go b/backend/app/http/super/v1/dto/super_report.go new file mode 100644 index 0000000..6e44eab --- /dev/null +++ b/backend/app/http/super/v1/dto/super_report.go @@ -0,0 +1,27 @@ +package dto + +// SuperReportOverviewFilter 超管报表查询条件。 +type SuperReportOverviewFilter struct { + // TenantID 租户ID(不传代表全平台)。 + TenantID *int64 `query:"tenant_id"` + // StartAt 统计开始时间(RFC3339,可选;默认当前时间往前 7 天)。 + StartAt *string `query:"start_at"` + // EndAt 统计结束时间(RFC3339,可选;默认当前时间)。 + EndAt *string `query:"end_at"` + // Granularity 统计粒度(day;目前仅支持 day)。 + Granularity *string `query:"granularity"` +} + +// SuperReportExportForm 超管报表导出参数。 +type SuperReportExportForm struct { + // TenantID 租户ID(不传代表全平台)。 + TenantID *int64 `json:"tenant_id"` + // StartAt 统计开始时间(RFC3339,可选;默认当前时间往前 7 天)。 + StartAt *string `json:"start_at"` + // EndAt 统计结束时间(RFC3339,可选;默认当前时间)。 + EndAt *string `json:"end_at"` + // Granularity 统计粒度(day;目前仅支持 day)。 + Granularity *string `json:"granularity"` + // Format 导出格式(仅支持 csv)。 + Format string `json:"format"` +} diff --git a/backend/app/http/super/v1/dto/super_withdrawal.go b/backend/app/http/super/v1/dto/super_withdrawal.go new file mode 100644 index 0000000..becbbfe --- /dev/null +++ b/backend/app/http/super/v1/dto/super_withdrawal.go @@ -0,0 +1,7 @@ +package dto + +// SuperWithdrawalRejectForm 超管驳回提现表单。 +type SuperWithdrawalRejectForm struct { + // Reason 驳回原因。 + Reason string `json:"reason" validate:"required"` +} diff --git a/backend/app/http/super/v1/provider.gen.go b/backend/app/http/super/v1/provider.gen.go index 5b5a80f..67310ab 100755 --- a/backend/app/http/super/v1/provider.gen.go +++ b/backend/app/http/super/v1/provider.gen.go @@ -17,6 +17,20 @@ func Provide(opts ...opt.Option) error { }); err != nil { return err } + if err := container.Container.Provide(func() (*coupons, error) { + obj := &coupons{} + + return obj, nil + }); err != nil { + return err + } + if err := container.Container.Provide(func() (*creators, error) { + obj := &creators{} + + return obj, nil + }); err != nil { + return err + } if err := container.Container.Provide(func() (*orders, error) { obj := &orders{} @@ -24,19 +38,34 @@ func Provide(opts ...opt.Option) error { }); err != nil { return err } + if err := container.Container.Provide(func() (*reports, error) { + obj := &reports{} + + return obj, nil + }); err != nil { + return err + } if err := container.Container.Provide(func( contents *contents, + coupons *coupons, + creators *creators, middlewares *middlewares.Middlewares, orders *orders, + reports *reports, tenants *tenants, users *users, + withdrawals *withdrawals, ) (contracts.HttpRoute, error) { obj := &Routes{ contents: contents, + coupons: coupons, + creators: creators, middlewares: middlewares, orders: orders, + reports: reports, tenants: tenants, users: users, + withdrawals: withdrawals, } if err := obj.Prepare(); err != nil { return nil, err @@ -60,5 +89,12 @@ func Provide(opts ...opt.Option) error { }); err != nil { return err } + if err := container.Container.Provide(func() (*withdrawals, error) { + obj := &withdrawals{} + + return obj, nil + }); err != nil { + return err + } return nil } diff --git a/backend/app/http/super/v1/reports.go b/backend/app/http/super/v1/reports.go new file mode 100644 index 0000000..5bfbca7 --- /dev/null +++ b/backend/app/http/super/v1/reports.go @@ -0,0 +1,41 @@ +package v1 + +import ( + dto "quyun/v2/app/http/super/v1/dto" + v1_dto "quyun/v2/app/http/v1/dto" + "quyun/v2/app/services" + + "github.com/gofiber/fiber/v3" +) + +// @provider +type reports struct{} + +// Report overview +// +// @Router /super/v1/reports/overview [get] +// @Summary Report overview +// @Description Get platform report overview +// @Tags Report +// @Accept json +// @Produce json +// @Success 200 {object} v1_dto.ReportOverviewResponse +// @Bind filter query +func (c *reports) Overview(ctx fiber.Ctx, filter *dto.SuperReportOverviewFilter) (*v1_dto.ReportOverviewResponse, error) { + return services.Super.ReportOverview(ctx, filter) +} + +// Export report +// +// @Router /super/v1/reports/export [post] +// @Summary Export report +// @Description Export platform report data +// @Tags Report +// @Accept json +// @Produce json +// @Param form body dto.SuperReportExportForm true "Export form" +// @Success 200 {object} v1_dto.ReportExportResponse +// @Bind form body +func (c *reports) Export(ctx fiber.Ctx, form *dto.SuperReportExportForm) (*v1_dto.ReportExportResponse, error) { + return services.Super.ExportReport(ctx, form) +} diff --git a/backend/app/http/super/v1/routes.gen.go b/backend/app/http/super/v1/routes.gen.go index 1fa40d4..550a255 100644 --- a/backend/app/http/super/v1/routes.gen.go +++ b/backend/app/http/super/v1/routes.gen.go @@ -24,10 +24,14 @@ type Routes struct { log *log.Entry `inject:"false"` middlewares *middlewares.Middlewares // Controller instances - contents *contents - orders *orders - tenants *tenants - users *users + contents *contents + coupons *coupons + creators *creators + orders *orders + reports *reports + tenants *tenants + users *users + withdrawals *withdrawals } // Prepare initializes the routes provider with logging configuration. @@ -71,6 +75,18 @@ func (r *Routes) Register(router fiber.Router) { PathParam[int64]("id"), Body[dto.SuperContentReviewForm]("form"), )) + // Register routes for controller: coupons + r.log.Debugf("Registering route: Get /super/v1/coupons -> coupons.List") + router.Get("/super/v1/coupons"[len(r.Path()):], DataFunc1( + r.coupons.List, + Query[dto.SuperCouponListFilter]("filter"), + )) + // Register routes for controller: creators + r.log.Debugf("Registering route: Get /super/v1/creators -> creators.List") + router.Get("/super/v1/creators"[len(r.Path()):], DataFunc1( + r.creators.List, + Query[dto.TenantListFilter]("filter"), + )) // Register routes for controller: orders r.log.Debugf("Registering route: Get /super/v1/orders -> orders.List") router.Get("/super/v1/orders"[len(r.Path()):], DataFunc1( @@ -92,6 +108,17 @@ func (r *Routes) Register(router fiber.Router) { PathParam[int64]("id"), Body[dto.SuperOrderRefundForm]("form"), )) + // Register routes for controller: reports + r.log.Debugf("Registering route: Get /super/v1/reports/overview -> reports.Overview") + router.Get("/super/v1/reports/overview"[len(r.Path()):], DataFunc1( + r.reports.Overview, + Query[dto.SuperReportOverviewFilter]("filter"), + )) + r.log.Debugf("Registering route: Post /super/v1/reports/export -> reports.Export") + router.Post("/super/v1/reports/export"[len(r.Path()):], DataFunc1( + r.reports.Export, + Body[dto.SuperReportExportForm]("form"), + )) // Register routes for controller: tenants r.log.Debugf("Registering route: Get /super/v1/tenants -> tenants.List") router.Get("/super/v1/tenants"[len(r.Path()):], DataFunc1( @@ -172,6 +199,25 @@ func (r *Routes) Register(router fiber.Router) { PathParam[int64]("id"), Body[dto.UserStatusUpdateForm]("form"), )) + // Register routes for controller: withdrawals + r.log.Debugf("Registering route: Get /super/v1/withdrawals -> withdrawals.List") + router.Get("/super/v1/withdrawals"[len(r.Path()):], DataFunc1( + r.withdrawals.List, + Query[dto.SuperOrderListFilter]("filter"), + )) + r.log.Debugf("Registering route: Post /super/v1/withdrawals/:id/approve -> withdrawals.Approve") + router.Post("/super/v1/withdrawals/:id/approve"[len(r.Path()):], Func2( + r.withdrawals.Approve, + Local[*models.User]("__ctx_user"), + PathParam[int64]("id"), + )) + r.log.Debugf("Registering route: Post /super/v1/withdrawals/:id/reject -> withdrawals.Reject") + router.Post("/super/v1/withdrawals/:id/reject"[len(r.Path()):], Func3( + r.withdrawals.Reject, + Local[*models.User]("__ctx_user"), + PathParam[int64]("id"), + Body[dto.SuperWithdrawalRejectForm]("form"), + )) r.log.Info("Successfully registered all routes") } diff --git a/backend/app/http/super/v1/withdrawals.go b/backend/app/http/super/v1/withdrawals.go new file mode 100644 index 0000000..779586f --- /dev/null +++ b/backend/app/http/super/v1/withdrawals.go @@ -0,0 +1,63 @@ +package v1 + +import ( + dto "quyun/v2/app/http/super/v1/dto" + "quyun/v2/app/requests" + "quyun/v2/app/services" + "quyun/v2/database/models" + + "github.com/gofiber/fiber/v3" +) + +// @provider +type withdrawals struct{} + +// List withdrawals +// +// @Router /super/v1/withdrawals [get] +// @Summary List withdrawals +// @Description List withdrawal orders across tenants +// @Tags Finance +// @Accept json +// @Produce json +// @Param page query int false "Page number" +// @Param limit query int false "Page size" +// @Success 200 {object} requests.Pager{items=[]dto.SuperOrderItem} +// @Bind filter query +func (c *withdrawals) List(ctx fiber.Ctx, filter *dto.SuperOrderListFilter) (*requests.Pager, error) { + return services.Super.ListWithdrawals(ctx, filter) +} + +// Approve withdrawal +// +// @Router /super/v1/withdrawals/:id/approve [post] +// @Summary Approve withdrawal +// @Description Approve a withdrawal request +// @Tags Finance +// @Accept json +// @Produce json +// @Param id path int64 true "Withdrawal order ID" +// @Success 200 {string} string "Approved" +// @Bind user local key(__ctx_user) +// @Bind id path +func (c *withdrawals) Approve(ctx fiber.Ctx, user *models.User, id int64) error { + return services.Super.ApproveWithdrawal(ctx, user.ID, id) +} + +// Reject withdrawal +// +// @Router /super/v1/withdrawals/:id/reject [post] +// @Summary Reject withdrawal +// @Description Reject a withdrawal request +// @Tags Finance +// @Accept json +// @Produce json +// @Param id path int64 true "Withdrawal order ID" +// @Param form body dto.SuperWithdrawalRejectForm true "Reject form" +// @Success 200 {string} string "Rejected" +// @Bind user local key(__ctx_user) +// @Bind id path +// @Bind form body +func (c *withdrawals) Reject(ctx fiber.Ctx, user *models.User, id int64, form *dto.SuperWithdrawalRejectForm) error { + return services.Super.RejectWithdrawal(ctx, user.ID, id, form.Reason) +} diff --git a/backend/app/services/super.go b/backend/app/services/super.go index ff1db34..1ebeecf 100644 --- a/backend/app/services/super.go +++ b/backend/app/services/super.go @@ -3,6 +3,7 @@ package services import ( "context" "errors" + "strconv" "strings" "time" @@ -2201,16 +2202,134 @@ func (s *super) evaluateTenantHealth(tenant *models.Tenant, metrics tenantHealth } func (s *super) ListWithdrawals(ctx context.Context, filter *super_dto.SuperOrderListFilter) (*requests.Pager, error) { + if filter == nil { + filter = &super_dto.SuperOrderListFilter{} + } + tbl, q := models.OrderQuery.QueryContext(ctx) q = q.Where(tbl.Type.Eq(consts.OrderTypeWithdrawal)) + if filter.ID != nil && *filter.ID > 0 { + q = q.Where(tbl.ID.Eq(*filter.ID)) + } + if filter.TenantID != nil && *filter.TenantID > 0 { + q = q.Where(tbl.TenantID.Eq(*filter.TenantID)) + } + if filter.UserID != nil && *filter.UserID > 0 { + q = q.Where(tbl.UserID.Eq(*filter.UserID)) + } + if filter.Status != nil && *filter.Status != "" { + q = q.Where(tbl.Status.Eq(*filter.Status)) + } + if filter.AmountPaidMin != nil { + q = q.Where(tbl.AmountPaid.Gte(*filter.AmountPaidMin)) + } + if filter.AmountPaidMax != nil { + q = q.Where(tbl.AmountPaid.Lte(*filter.AmountPaidMax)) + } + + tenantIDs, tenantFilter, err := s.lookupTenantIDs(ctx, filter.TenantCode, filter.TenantName) + if err != nil { + return nil, err + } + if tenantFilter { + if len(tenantIDs) == 0 { + q = q.Where(tbl.ID.Eq(-1)) + } else { + q = q.Where(tbl.TenantID.In(tenantIDs...)) + } + } + + userIDs, userFilter, err := s.lookupUserIDs(ctx, filter.Username) + if err != nil { + return nil, err + } + if userFilter { + if len(userIDs) == 0 { + q = q.Where(tbl.ID.Eq(-1)) + } else { + q = q.Where(tbl.UserID.In(userIDs...)) + } + } + + if filter.CreatedAtFrom != nil { + from, err := s.parseFilterTime(filter.CreatedAtFrom) + if err != nil { + return nil, err + } + if from != nil { + q = q.Where(tbl.CreatedAt.Gte(*from)) + } + } + if filter.CreatedAtTo != nil { + to, err := s.parseFilterTime(filter.CreatedAtTo) + if err != nil { + return nil, err + } + if to != nil { + q = q.Where(tbl.CreatedAt.Lte(*to)) + } + } + if filter.PaidAtFrom != nil { + from, err := s.parseFilterTime(filter.PaidAtFrom) + if err != nil { + return nil, err + } + if from != nil { + q = q.Where(tbl.PaidAt.Gte(*from)) + } + } + if filter.PaidAtTo != nil { + to, err := s.parseFilterTime(filter.PaidAtTo) + if err != nil { + return nil, err + } + if to != nil { + q = q.Where(tbl.PaidAt.Lte(*to)) + } + } + + orderApplied := false + if filter.Desc != nil && strings.TrimSpace(*filter.Desc) != "" { + switch strings.TrimSpace(*filter.Desc) { + case "id": + q = q.Order(tbl.ID.Desc()) + case "created_at": + q = q.Order(tbl.CreatedAt.Desc()) + case "paid_at": + q = q.Order(tbl.PaidAt.Desc()) + case "amount_paid": + q = q.Order(tbl.AmountPaid.Desc()) + case "status": + q = q.Order(tbl.Status.Desc()) + } + orderApplied = true + } else if filter.Asc != nil && strings.TrimSpace(*filter.Asc) != "" { + switch strings.TrimSpace(*filter.Asc) { + case "id": + q = q.Order(tbl.ID) + case "created_at": + q = q.Order(tbl.CreatedAt) + case "paid_at": + q = q.Order(tbl.PaidAt) + case "amount_paid": + q = q.Order(tbl.AmountPaid) + case "status": + q = q.Order(tbl.Status) + } + orderApplied = true + } + if !orderApplied { + q = q.Order(tbl.ID.Desc()) + } + filter.Pagination.Format() total, err := q.Count() if err != nil { return nil, errorx.ErrDatabaseError.WithCause(err) } - list, err := q.Offset(int(filter.Pagination.Offset())).Limit(int(filter.Pagination.Limit)).Order(tbl.ID.Desc()).Find() + list, err := q.Offset(int(filter.Pagination.Offset())).Limit(int(filter.Pagination.Limit)).Find() if err != nil { return nil, errorx.ErrDatabaseError.WithCause(err) } @@ -2226,6 +2345,436 @@ func (s *super) ListWithdrawals(ctx context.Context, filter *super_dto.SuperOrde }, nil } +func (s *super) ListCoupons(ctx context.Context, filter *super_dto.SuperCouponListFilter) (*requests.Pager, error) { + if filter == nil { + filter = &super_dto.SuperCouponListFilter{} + } + + tbl, q := models.CouponQuery.QueryContext(ctx) + if filter.ID != nil && *filter.ID > 0 { + q = q.Where(tbl.ID.Eq(*filter.ID)) + } + if filter.TenantID != nil && *filter.TenantID > 0 { + q = q.Where(tbl.TenantID.Eq(*filter.TenantID)) + } + + tenantIDs, tenantFilter, err := s.lookupTenantIDs(ctx, filter.TenantCode, filter.TenantName) + if err != nil { + return nil, err + } + if tenantFilter { + if len(tenantIDs) == 0 { + q = q.Where(tbl.ID.Eq(-1)) + } else { + q = q.Where(tbl.TenantID.In(tenantIDs...)) + } + } + + if filter.Type != nil && strings.TrimSpace(*filter.Type) != "" { + parsed, err := consts.ParseCouponType(strings.TrimSpace(*filter.Type)) + if err != nil { + return nil, errorx.ErrInvalidParameter.WithCause(err).WithMsg("优惠券类型无效") + } + q = q.Where(tbl.Type.Eq(parsed)) + } + if filter.Keyword != nil && strings.TrimSpace(*filter.Keyword) != "" { + keyword := strings.TrimSpace(*filter.Keyword) + q = q.Where(field.Or( + tbl.Title.Like("%"+keyword+"%"), + tbl.Description.Like("%"+keyword+"%"), + )) + } + if filter.Status != nil && strings.TrimSpace(*filter.Status) != "" { + status := strings.ToLower(strings.TrimSpace(*filter.Status)) + now := time.Now() + switch status { + case "active": + q = q.Where(field.Or(tbl.StartAt.Lte(now), tbl.StartAt.IsNull())) + q = q.Where(field.Or(tbl.EndAt.Gte(now), tbl.EndAt.IsNull())) + case "expired": + q = q.Where(tbl.EndAt.IsNotNull(), tbl.EndAt.Lt(now)) + case "upcoming": + q = q.Where(tbl.StartAt.IsNotNull(), tbl.StartAt.Gt(now)) + default: + return nil, errorx.ErrInvalidParameter.WithMsg("状态参数无效") + } + } + + if filter.CreatedAtFrom != nil { + from, err := s.parseFilterTime(filter.CreatedAtFrom) + if err != nil { + return nil, err + } + if from != nil { + q = q.Where(tbl.CreatedAt.Gte(*from)) + } + } + if filter.CreatedAtTo != nil { + to, err := s.parseFilterTime(filter.CreatedAtTo) + if err != nil { + return nil, err + } + if to != nil { + q = q.Where(tbl.CreatedAt.Lte(*to)) + } + } + + orderApplied := false + if filter.Desc != nil && strings.TrimSpace(*filter.Desc) != "" { + switch strings.TrimSpace(*filter.Desc) { + case "id": + q = q.Order(tbl.ID.Desc()) + case "created_at": + q = q.Order(tbl.CreatedAt.Desc()) + case "start_at": + q = q.Order(tbl.StartAt.Desc()) + case "end_at": + q = q.Order(tbl.EndAt.Desc()) + } + orderApplied = true + } else if filter.Asc != nil && strings.TrimSpace(*filter.Asc) != "" { + switch strings.TrimSpace(*filter.Asc) { + case "id": + q = q.Order(tbl.ID) + case "created_at": + q = q.Order(tbl.CreatedAt) + case "start_at": + q = q.Order(tbl.StartAt) + case "end_at": + q = q.Order(tbl.EndAt) + } + orderApplied = true + } + if !orderApplied { + q = q.Order(tbl.CreatedAt.Desc()) + } + + filter.Pagination.Format() + total, err := q.Count() + if err != nil { + return nil, errorx.ErrDatabaseError.WithCause(err) + } + + list, err := q.Offset(int(filter.Pagination.Offset())).Limit(int(filter.Pagination.Limit)).Find() + if err != nil { + return nil, errorx.ErrDatabaseError.WithCause(err) + } + + items, err := s.buildSuperCouponItems(ctx, list) + if err != nil { + return nil, err + } + return &requests.Pager{ + Pagination: filter.Pagination, + Total: total, + Items: items, + }, nil +} + +func (s *super) ReportOverview(ctx context.Context, filter *super_dto.SuperReportOverviewFilter) (*v1_dto.ReportOverviewResponse, error) { + // 统一统计时间范围与粒度。 + rg, err := s.normalizeReportRange(filter) + if err != nil { + return nil, err + } + + tenantID := int64(0) + if filter != nil && filter.TenantID != nil { + tenantID = *filter.TenantID + } + + // 统计累计曝光(全量累计值,暂无按时间拆分的曝光记录)。 + var totalViews int64 + contentQuery := models.ContentQuery.WithContext(ctx). + UnderlyingDB(). + Model(&models.Content{}). + Select("coalesce(sum(views), 0)") + if tenantID > 0 { + contentQuery = contentQuery.Where("tenant_id = ?", tenantID) + } + if err := contentQuery.Scan(&totalViews).Error; err != nil { + return nil, errorx.ErrDatabaseError.WithCause(err) + } + + // 订单仅统计内容购买类型,并按状态划分已支付/已退款。 + paidCount, paidAmount, err := s.reportOrderAggregate(ctx, tenantID, consts.OrderStatusPaid, "paid_at", rg) + if err != nil { + return nil, err + } + refundCount, refundAmount, err := s.reportOrderAggregate(ctx, tenantID, consts.OrderStatusRefunded, "updated_at", rg) + if err != nil { + return nil, err + } + + conversionRate := 0.0 + if totalViews > 0 { + conversionRate = float64(paidCount) / float64(totalViews) + } + + paidSeries, err := s.reportOrderSeries(ctx, tenantID, consts.OrderStatusPaid, "paid_at", rg) + if err != nil { + return nil, err + } + refundSeries, err := s.reportOrderSeries(ctx, tenantID, consts.OrderStatusRefunded, "updated_at", rg) + if err != nil { + return nil, err + } + + items := make([]v1_dto.ReportOverviewItem, 0) + for day := rg.startDay; !day.After(rg.endDay); day = day.AddDate(0, 0, 1) { + key := day.Format("2006-01-02") + paidItem := paidSeries[key] + refundItem := refundSeries[key] + items = append(items, v1_dto.ReportOverviewItem{ + Date: key, + PaidOrders: paidItem.Count, + PaidAmount: float64(paidItem.Amount) / 100.0, + RefundOrders: refundItem.Count, + RefundAmount: float64(refundItem.Amount) / 100.0, + }) + } + + return &v1_dto.ReportOverviewResponse{ + Summary: v1_dto.ReportSummary{ + TotalViews: totalViews, + PaidOrders: paidCount, + PaidAmount: float64(paidAmount) / 100.0, + RefundOrders: refundCount, + RefundAmount: float64(refundAmount) / 100.0, + ConversionRate: conversionRate, + }, + Items: items, + }, nil +} + +func (s *super) ExportReport(ctx context.Context, form *super_dto.SuperReportExportForm) (*v1_dto.ReportExportResponse, error) { + if form == nil { + return nil, errorx.ErrBadRequest.WithMsg("导出参数不能为空") + } + format := strings.ToLower(strings.TrimSpace(form.Format)) + if format == "" { + format = "csv" + } + if format != "csv" { + return nil, errorx.ErrBadRequest.WithMsg("仅支持 CSV 导出") + } + + overview, err := s.ReportOverview(ctx, &super_dto.SuperReportOverviewFilter{ + TenantID: form.TenantID, + StartAt: form.StartAt, + EndAt: form.EndAt, + Granularity: form.Granularity, + }) + if err != nil { + return nil, err + } + + builder := &strings.Builder{} + builder.WriteString("date,paid_orders,paid_amount,refund_orders,refund_amount\n") + for _, item := range overview.Items { + builder.WriteString(item.Date) + builder.WriteString(",") + builder.WriteString(strconv.FormatInt(item.PaidOrders, 10)) + builder.WriteString(",") + builder.WriteString(formatAmount(item.PaidAmount)) + builder.WriteString(",") + builder.WriteString(strconv.FormatInt(item.RefundOrders, 10)) + builder.WriteString(",") + builder.WriteString(formatAmount(item.RefundAmount)) + builder.WriteString("\n") + } + + filename := "report_overview_" + time.Now().Format("20060102_150405") + ".csv" + return &v1_dto.ReportExportResponse{ + Filename: filename, + MimeType: "text/csv", + Content: builder.String(), + }, nil +} + +func (s *super) reportOrderAggregate( + ctx context.Context, + tenantID int64, + status consts.OrderStatus, + timeField string, + rg reportRange, +) (int64, int64, error) { + var total struct { + Count int64 `gorm:"column:count"` + Amount int64 `gorm:"column:amount"` + } + + query := models.OrderQuery.WithContext(ctx). + UnderlyingDB(). + Model(&models.Order{}). + Select("count(*) as count, coalesce(sum(amount_paid), 0) as amount"). + Where("type = ? AND status = ? AND "+timeField+" >= ? AND "+timeField+" < ?", + consts.OrderTypeContentPurchase, status, rg.startDay, rg.endNext) + if tenantID > 0 { + query = query.Where("tenant_id = ?", tenantID) + } + + if err := query.Scan(&total).Error; err != nil { + return 0, 0, errorx.ErrDatabaseError.WithCause(err) + } + return total.Count, total.Amount, nil +} + +func (s *super) reportOrderSeries( + ctx context.Context, + tenantID int64, + status consts.OrderStatus, + timeField string, + rg reportRange, +) (map[string]reportAggRow, error) { + rows := make([]reportAggRow, 0) + query := models.OrderQuery.WithContext(ctx). + UnderlyingDB(). + Model(&models.Order{}). + Select("date_trunc('day', "+timeField+") as day, count(*) as count, coalesce(sum(amount_paid), 0) as amount"). + Where("type = ? AND status = ? AND "+timeField+" >= ? AND "+timeField+" < ?", + consts.OrderTypeContentPurchase, status, rg.startDay, rg.endNext) + if tenantID > 0 { + query = query.Where("tenant_id = ?", tenantID) + } + + if err := query.Group("day").Scan(&rows).Error; err != nil { + return nil, errorx.ErrDatabaseError.WithCause(err) + } + + result := make(map[string]reportAggRow, len(rows)) + for _, row := range rows { + key := row.Day.Format("2006-01-02") + result[key] = row + } + return result, nil +} + +func (s *super) normalizeReportRange(filter *super_dto.SuperReportOverviewFilter) (reportRange, error) { + granularity := "day" + if filter != nil && filter.Granularity != nil && strings.TrimSpace(*filter.Granularity) != "" { + granularity = strings.ToLower(strings.TrimSpace(*filter.Granularity)) + } + if granularity != "day" { + return reportRange{}, errorx.ErrBadRequest.WithMsg("仅支持按天统计") + } + + now := time.Now() + endAt := now + if filter != nil && filter.EndAt != nil && strings.TrimSpace(*filter.EndAt) != "" { + parsed, err := time.Parse(time.RFC3339, strings.TrimSpace(*filter.EndAt)) + if err != nil { + return reportRange{}, errorx.ErrBadRequest.WithMsg("结束时间格式错误") + } + endAt = parsed + } + + startAt := endAt.AddDate(0, 0, -6) + if filter != nil && filter.StartAt != nil && strings.TrimSpace(*filter.StartAt) != "" { + parsed, err := time.Parse(time.RFC3339, strings.TrimSpace(*filter.StartAt)) + if err != nil { + return reportRange{}, errorx.ErrBadRequest.WithMsg("开始时间格式错误") + } + startAt = parsed + } + + startDay := time.Date(startAt.Year(), startAt.Month(), startAt.Day(), 0, 0, 0, 0, startAt.Location()) + endDay := time.Date(endAt.Year(), endAt.Month(), endAt.Day(), 0, 0, 0, 0, endAt.Location()) + if endDay.Before(startDay) { + return reportRange{}, errorx.ErrBadRequest.WithMsg("结束时间不能早于开始时间") + } + + endNext := endDay.AddDate(0, 0, 1) + return reportRange{ + startDay: startDay, + endDay: endDay, + endNext: endNext, + }, nil +} + +func (s *super) buildSuperCouponItems(ctx context.Context, list []*models.Coupon) ([]super_dto.SuperCouponItem, error) { + if len(list) == 0 { + return []super_dto.SuperCouponItem{}, nil + } + + tenantIDs := make([]int64, 0, len(list)) + seen := make(map[int64]struct{}, len(list)) + for _, c := range list { + if c == nil { + continue + } + if _, ok := seen[c.TenantID]; ok { + continue + } + seen[c.TenantID] = struct{}{} + tenantIDs = append(tenantIDs, c.TenantID) + } + + tenantMap := make(map[int64]*models.Tenant, len(tenantIDs)) + if len(tenantIDs) > 0 { + tbl, q := models.TenantQuery.QueryContext(ctx) + tenants, err := q.Where(tbl.ID.In(tenantIDs...)).Find() + if err != nil { + return nil, errorx.ErrDatabaseError.WithCause(err) + } + for _, t := range tenants { + tenantMap[t.ID] = t + } + } + + items := make([]super_dto.SuperCouponItem, 0, len(list)) + for _, c := range list { + if c == nil { + continue + } + items = append(items, s.toSuperCouponItem(c, tenantMap[c.TenantID])) + } + return items, nil +} + +func (s *super) toSuperCouponItem(c *models.Coupon, tenant *models.Tenant) super_dto.SuperCouponItem { + status, statusDescription := s.resolveCouponStatus(c) + item := super_dto.SuperCouponItem{ + ID: c.ID, + TenantID: c.TenantID, + Title: c.Title, + Description: c.Description, + Type: c.Type, + TypeDescription: c.Type.Description(), + Value: c.Value, + MinOrderAmount: c.MinOrderAmount, + MaxDiscount: c.MaxDiscount, + TotalQuantity: c.TotalQuantity, + UsedQuantity: c.UsedQuantity, + Status: status, + StatusDescription: statusDescription, + CreatedAt: s.formatTime(c.CreatedAt), + UpdatedAt: s.formatTime(c.UpdatedAt), + } + if tenant != nil { + item.TenantCode = tenant.Code + item.TenantName = tenant.Name + } + if !c.StartAt.IsZero() { + item.StartAt = s.formatTime(c.StartAt) + } + if !c.EndAt.IsZero() { + item.EndAt = s.formatTime(c.EndAt) + } + return item +} + +func (s *super) resolveCouponStatus(c *models.Coupon) (string, string) { + now := time.Now() + if !c.EndAt.IsZero() && c.EndAt.Before(now) { + return "expired", "已过期" + } + if !c.StartAt.IsZero() && c.StartAt.After(now) { + return "upcoming", "未开始" + } + return "active", "生效中" +} + func (s *super) ApproveWithdrawal(ctx context.Context, operatorID, id int64) error { if operatorID == 0 { return errorx.ErrUnauthorized.WithMsg("缺少操作者信息") diff --git a/backend/docs/docs.go b/backend/docs/docs.go index aca6f7b..9006140 100644 --- a/backend/docs/docs.go +++ b/backend/docs/docs.go @@ -175,6 +175,110 @@ const docTemplate = `{ } } }, + "/super/v1/coupons": { + "get": { + "description": "List coupon templates across tenants", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Coupon" + ], + "summary": "List coupons", + "parameters": [ + { + "type": "integer", + "description": "Page number", + "name": "page", + "in": "query" + }, + { + "type": "integer", + "description": "Page size", + "name": "limit", + "in": "query" + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/requests.Pager" + }, + { + "type": "object", + "properties": { + "items": { + "type": "array", + "items": { + "$ref": "#/definitions/dto.SuperCouponItem" + } + } + } + } + ] + } + } + } + } + }, + "/super/v1/creators": { + "get": { + "description": "List creator tenants (channels) across the platform", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Creator" + ], + "summary": "List creators", + "parameters": [ + { + "type": "integer", + "description": "Page number", + "name": "page", + "in": "query" + }, + { + "type": "integer", + "description": "Page size", + "name": "limit", + "in": "query" + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/requests.Pager" + }, + { + "type": "object", + "properties": { + "items": { + "type": "array", + "items": { + "$ref": "#/definitions/dto.TenantItem" + } + } + } + } + ] + } + } + } + } + }, "/super/v1/orders": { "get": { "description": "List orders", @@ -325,6 +429,63 @@ const docTemplate = `{ } } }, + "/super/v1/reports/export": { + "post": { + "description": "Export platform report data", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Report" + ], + "summary": "Export report", + "parameters": [ + { + "description": "Export form", + "name": "form", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/dto.SuperReportExportForm" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/dto.ReportExportResponse" + } + } + } + } + }, + "/super/v1/reports/overview": { + "get": { + "description": "Get platform report overview", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Report" + ], + "summary": "Report overview", + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/dto.ReportOverviewResponse" + } + } + } + } + }, "/super/v1/tenants": { "get": { "description": "List tenants", @@ -1065,6 +1226,133 @@ const docTemplate = `{ } } }, + "/super/v1/withdrawals": { + "get": { + "description": "List withdrawal orders across tenants", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Finance" + ], + "summary": "List withdrawals", + "parameters": [ + { + "type": "integer", + "description": "Page number", + "name": "page", + "in": "query" + }, + { + "type": "integer", + "description": "Page size", + "name": "limit", + "in": "query" + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/requests.Pager" + }, + { + "type": "object", + "properties": { + "items": { + "type": "array", + "items": { + "$ref": "#/definitions/dto.SuperOrderItem" + } + } + } + } + ] + } + } + } + } + }, + "/super/v1/withdrawals/{id}/approve": { + "post": { + "description": "Approve a withdrawal request", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Finance" + ], + "summary": "Approve withdrawal", + "parameters": [ + { + "type": "integer", + "format": "int64", + "description": "Withdrawal order ID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "Approved", + "schema": { + "type": "string" + } + } + } + } + }, + "/super/v1/withdrawals/{id}/reject": { + "post": { + "description": "Reject a withdrawal request", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Finance" + ], + "summary": "Reject withdrawal", + "parameters": [ + { + "type": "integer", + "format": "int64", + "description": "Withdrawal order ID", + "name": "id", + "in": "path", + "required": true + }, + { + "description": "Reject form", + "name": "form", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/dto.SuperWithdrawalRejectForm" + } + } + ], + "responses": { + "200": { + "description": "Rejected", + "schema": { + "type": "string" + } + } + } + } + }, "/t/{tenantCode}/v1/auth/login": { "post": { "description": "Login or register user using phone number and OTP", @@ -3822,6 +4110,17 @@ const docTemplate = `{ "ContentStatusBlocked" ] }, + "consts.CouponType": { + "type": "string", + "enum": [ + "fix_amount", + "discount" + ], + "x-enum-varnames": [ + "CouponTypeFixAmount", + "CouponTypeDiscount" + ] + }, "consts.Currency": { "type": "string", "enum": [ @@ -5240,6 +5539,91 @@ const docTemplate = `{ } } }, + "dto.SuperCouponItem": { + "type": "object", + "properties": { + "created_at": { + "description": "CreatedAt 创建时间(RFC3339)。", + "type": "string" + }, + "description": { + "description": "Description 优惠券描述。", + "type": "string" + }, + "end_at": { + "description": "EndAt 结束时间(RFC3339)。", + "type": "string" + }, + "id": { + "description": "ID 优惠券ID。", + "type": "integer" + }, + "max_discount": { + "description": "MaxDiscount 最大折扣金额(折扣券)。", + "type": "integer" + }, + "min_order_amount": { + "description": "MinOrderAmount 最低订单金额门槛。", + "type": "integer" + }, + "start_at": { + "description": "StartAt 生效时间(RFC3339)。", + "type": "string" + }, + "status": { + "description": "Status 状态(active/expired/upcoming)。", + "type": "string" + }, + "status_description": { + "description": "StatusDescription 状态描述(用于展示)。", + "type": "string" + }, + "tenant_code": { + "description": "TenantCode 租户编码。", + "type": "string" + }, + "tenant_id": { + "description": "TenantID 租户ID。", + "type": "integer" + }, + "tenant_name": { + "description": "TenantName 租户名称。", + "type": "string" + }, + "title": { + "description": "Title 优惠券标题。", + "type": "string" + }, + "total_quantity": { + "description": "TotalQuantity 总发行数量(0 表示不限量)。", + "type": "integer" + }, + "type": { + "description": "Type 优惠券类型。", + "allOf": [ + { + "$ref": "#/definitions/consts.CouponType" + } + ] + }, + "type_description": { + "description": "TypeDescription 类型描述(用于展示)。", + "type": "string" + }, + "updated_at": { + "description": "UpdatedAt 更新时间(RFC3339)。", + "type": "string" + }, + "used_quantity": { + "description": "UsedQuantity 已使用数量。", + "type": "integer" + }, + "value": { + "description": "Value 优惠券面额/折扣值。", + "type": "integer" + } + } + }, "dto.SuperOrderDetail": { "type": "object", "properties": { @@ -5397,6 +5781,31 @@ const docTemplate = `{ } } }, + "dto.SuperReportExportForm": { + "type": "object", + "properties": { + "end_at": { + "description": "EndAt 统计结束时间(RFC3339,可选;默认当前时间)。", + "type": "string" + }, + "format": { + "description": "Format 导出格式(仅支持 csv)。", + "type": "string" + }, + "granularity": { + "description": "Granularity 统计粒度(day;目前仅支持 day)。", + "type": "string" + }, + "start_at": { + "description": "StartAt 统计开始时间(RFC3339,可选;默认当前时间往前 7 天)。", + "type": "string" + }, + "tenant_id": { + "description": "TenantID 租户ID(不传代表全平台)。", + "type": "integer" + } + } + }, "dto.SuperTenantContentStatusUpdateForm": { "type": "object", "required": [ @@ -5482,6 +5891,18 @@ const docTemplate = `{ } } }, + "dto.SuperWithdrawalRejectForm": { + "type": "object", + "required": [ + "reason" + ], + "properties": { + "reason": { + "description": "Reason 驳回原因。", + "type": "string" + } + } + }, "dto.TenantAdminUserLite": { "type": "object", "properties": { diff --git a/backend/docs/swagger.json b/backend/docs/swagger.json index eddaf88..5def424 100644 --- a/backend/docs/swagger.json +++ b/backend/docs/swagger.json @@ -169,6 +169,110 @@ } } }, + "/super/v1/coupons": { + "get": { + "description": "List coupon templates across tenants", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Coupon" + ], + "summary": "List coupons", + "parameters": [ + { + "type": "integer", + "description": "Page number", + "name": "page", + "in": "query" + }, + { + "type": "integer", + "description": "Page size", + "name": "limit", + "in": "query" + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/requests.Pager" + }, + { + "type": "object", + "properties": { + "items": { + "type": "array", + "items": { + "$ref": "#/definitions/dto.SuperCouponItem" + } + } + } + } + ] + } + } + } + } + }, + "/super/v1/creators": { + "get": { + "description": "List creator tenants (channels) across the platform", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Creator" + ], + "summary": "List creators", + "parameters": [ + { + "type": "integer", + "description": "Page number", + "name": "page", + "in": "query" + }, + { + "type": "integer", + "description": "Page size", + "name": "limit", + "in": "query" + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/requests.Pager" + }, + { + "type": "object", + "properties": { + "items": { + "type": "array", + "items": { + "$ref": "#/definitions/dto.TenantItem" + } + } + } + } + ] + } + } + } + } + }, "/super/v1/orders": { "get": { "description": "List orders", @@ -319,6 +423,63 @@ } } }, + "/super/v1/reports/export": { + "post": { + "description": "Export platform report data", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Report" + ], + "summary": "Export report", + "parameters": [ + { + "description": "Export form", + "name": "form", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/dto.SuperReportExportForm" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/dto.ReportExportResponse" + } + } + } + } + }, + "/super/v1/reports/overview": { + "get": { + "description": "Get platform report overview", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Report" + ], + "summary": "Report overview", + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/dto.ReportOverviewResponse" + } + } + } + } + }, "/super/v1/tenants": { "get": { "description": "List tenants", @@ -1059,6 +1220,133 @@ } } }, + "/super/v1/withdrawals": { + "get": { + "description": "List withdrawal orders across tenants", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Finance" + ], + "summary": "List withdrawals", + "parameters": [ + { + "type": "integer", + "description": "Page number", + "name": "page", + "in": "query" + }, + { + "type": "integer", + "description": "Page size", + "name": "limit", + "in": "query" + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/requests.Pager" + }, + { + "type": "object", + "properties": { + "items": { + "type": "array", + "items": { + "$ref": "#/definitions/dto.SuperOrderItem" + } + } + } + } + ] + } + } + } + } + }, + "/super/v1/withdrawals/{id}/approve": { + "post": { + "description": "Approve a withdrawal request", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Finance" + ], + "summary": "Approve withdrawal", + "parameters": [ + { + "type": "integer", + "format": "int64", + "description": "Withdrawal order ID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "Approved", + "schema": { + "type": "string" + } + } + } + } + }, + "/super/v1/withdrawals/{id}/reject": { + "post": { + "description": "Reject a withdrawal request", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Finance" + ], + "summary": "Reject withdrawal", + "parameters": [ + { + "type": "integer", + "format": "int64", + "description": "Withdrawal order ID", + "name": "id", + "in": "path", + "required": true + }, + { + "description": "Reject form", + "name": "form", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/dto.SuperWithdrawalRejectForm" + } + } + ], + "responses": { + "200": { + "description": "Rejected", + "schema": { + "type": "string" + } + } + } + } + }, "/t/{tenantCode}/v1/auth/login": { "post": { "description": "Login or register user using phone number and OTP", @@ -3816,6 +4104,17 @@ "ContentStatusBlocked" ] }, + "consts.CouponType": { + "type": "string", + "enum": [ + "fix_amount", + "discount" + ], + "x-enum-varnames": [ + "CouponTypeFixAmount", + "CouponTypeDiscount" + ] + }, "consts.Currency": { "type": "string", "enum": [ @@ -5234,6 +5533,91 @@ } } }, + "dto.SuperCouponItem": { + "type": "object", + "properties": { + "created_at": { + "description": "CreatedAt 创建时间(RFC3339)。", + "type": "string" + }, + "description": { + "description": "Description 优惠券描述。", + "type": "string" + }, + "end_at": { + "description": "EndAt 结束时间(RFC3339)。", + "type": "string" + }, + "id": { + "description": "ID 优惠券ID。", + "type": "integer" + }, + "max_discount": { + "description": "MaxDiscount 最大折扣金额(折扣券)。", + "type": "integer" + }, + "min_order_amount": { + "description": "MinOrderAmount 最低订单金额门槛。", + "type": "integer" + }, + "start_at": { + "description": "StartAt 生效时间(RFC3339)。", + "type": "string" + }, + "status": { + "description": "Status 状态(active/expired/upcoming)。", + "type": "string" + }, + "status_description": { + "description": "StatusDescription 状态描述(用于展示)。", + "type": "string" + }, + "tenant_code": { + "description": "TenantCode 租户编码。", + "type": "string" + }, + "tenant_id": { + "description": "TenantID 租户ID。", + "type": "integer" + }, + "tenant_name": { + "description": "TenantName 租户名称。", + "type": "string" + }, + "title": { + "description": "Title 优惠券标题。", + "type": "string" + }, + "total_quantity": { + "description": "TotalQuantity 总发行数量(0 表示不限量)。", + "type": "integer" + }, + "type": { + "description": "Type 优惠券类型。", + "allOf": [ + { + "$ref": "#/definitions/consts.CouponType" + } + ] + }, + "type_description": { + "description": "TypeDescription 类型描述(用于展示)。", + "type": "string" + }, + "updated_at": { + "description": "UpdatedAt 更新时间(RFC3339)。", + "type": "string" + }, + "used_quantity": { + "description": "UsedQuantity 已使用数量。", + "type": "integer" + }, + "value": { + "description": "Value 优惠券面额/折扣值。", + "type": "integer" + } + } + }, "dto.SuperOrderDetail": { "type": "object", "properties": { @@ -5391,6 +5775,31 @@ } } }, + "dto.SuperReportExportForm": { + "type": "object", + "properties": { + "end_at": { + "description": "EndAt 统计结束时间(RFC3339,可选;默认当前时间)。", + "type": "string" + }, + "format": { + "description": "Format 导出格式(仅支持 csv)。", + "type": "string" + }, + "granularity": { + "description": "Granularity 统计粒度(day;目前仅支持 day)。", + "type": "string" + }, + "start_at": { + "description": "StartAt 统计开始时间(RFC3339,可选;默认当前时间往前 7 天)。", + "type": "string" + }, + "tenant_id": { + "description": "TenantID 租户ID(不传代表全平台)。", + "type": "integer" + } + } + }, "dto.SuperTenantContentStatusUpdateForm": { "type": "object", "required": [ @@ -5476,6 +5885,18 @@ } } }, + "dto.SuperWithdrawalRejectForm": { + "type": "object", + "required": [ + "reason" + ], + "properties": { + "reason": { + "description": "Reason 驳回原因。", + "type": "string" + } + } + }, "dto.TenantAdminUserLite": { "type": "object", "properties": { diff --git a/backend/docs/swagger.yaml b/backend/docs/swagger.yaml index f4c87a6..70fedde 100644 --- a/backend/docs/swagger.yaml +++ b/backend/docs/swagger.yaml @@ -14,6 +14,14 @@ definitions: - ContentStatusPublished - ContentStatusUnpublished - ContentStatusBlocked + consts.CouponType: + enum: + - fix_amount + - discount + type: string + x-enum-varnames: + - CouponTypeFixAmount + - CouponTypeDiscount consts.Currency: enum: - CNY @@ -1021,6 +1029,67 @@ definitions: description: Name 租户名称。 type: string type: object + dto.SuperCouponItem: + properties: + created_at: + description: CreatedAt 创建时间(RFC3339)。 + type: string + description: + description: Description 优惠券描述。 + type: string + end_at: + description: EndAt 结束时间(RFC3339)。 + type: string + id: + description: ID 优惠券ID。 + type: integer + max_discount: + description: MaxDiscount 最大折扣金额(折扣券)。 + type: integer + min_order_amount: + description: MinOrderAmount 最低订单金额门槛。 + type: integer + start_at: + description: StartAt 生效时间(RFC3339)。 + type: string + status: + description: Status 状态(active/expired/upcoming)。 + type: string + status_description: + description: StatusDescription 状态描述(用于展示)。 + type: string + tenant_code: + description: TenantCode 租户编码。 + type: string + tenant_id: + description: TenantID 租户ID。 + type: integer + tenant_name: + description: TenantName 租户名称。 + type: string + title: + description: Title 优惠券标题。 + type: string + total_quantity: + description: TotalQuantity 总发行数量(0 表示不限量)。 + type: integer + type: + allOf: + - $ref: '#/definitions/consts.CouponType' + description: Type 优惠券类型。 + type_description: + description: TypeDescription 类型描述(用于展示)。 + type: string + updated_at: + description: UpdatedAt 更新时间(RFC3339)。 + type: string + used_quantity: + description: UsedQuantity 已使用数量。 + type: integer + value: + description: Value 优惠券面额/折扣值。 + type: integer + type: object dto.SuperOrderDetail: properties: buyer: @@ -1119,6 +1188,24 @@ definitions: description: Reason 退款原因说明。 type: string type: object + dto.SuperReportExportForm: + properties: + end_at: + description: EndAt 统计结束时间(RFC3339,可选;默认当前时间)。 + type: string + format: + description: Format 导出格式(仅支持 csv)。 + type: string + granularity: + description: Granularity 统计粒度(day;目前仅支持 day)。 + type: string + start_at: + description: StartAt 统计开始时间(RFC3339,可选;默认当前时间往前 7 天)。 + type: string + tenant_id: + description: TenantID 租户ID(不传代表全平台)。 + type: integer + type: object dto.SuperTenantContentStatusUpdateForm: properties: status: @@ -1172,6 +1259,14 @@ definitions: description: VerifiedAt 实名认证时间(RFC3339)。 type: string type: object + dto.SuperWithdrawalRejectForm: + properties: + reason: + description: Reason 驳回原因。 + type: string + required: + - reason + type: object dto.TenantAdminUserLite: properties: id: @@ -2043,6 +2138,68 @@ paths: summary: Review content tags: - Content + /super/v1/coupons: + get: + consumes: + - application/json + description: List coupon templates across tenants + parameters: + - description: Page number + in: query + name: page + type: integer + - description: Page size + in: query + name: limit + type: integer + produces: + - application/json + responses: + "200": + description: OK + schema: + allOf: + - $ref: '#/definitions/requests.Pager' + - properties: + items: + items: + $ref: '#/definitions/dto.SuperCouponItem' + type: array + type: object + summary: List coupons + tags: + - Coupon + /super/v1/creators: + get: + consumes: + - application/json + description: List creator tenants (channels) across the platform + parameters: + - description: Page number + in: query + name: page + type: integer + - description: Page size + in: query + name: limit + type: integer + produces: + - application/json + responses: + "200": + description: OK + schema: + allOf: + - $ref: '#/definitions/requests.Pager' + - properties: + items: + items: + $ref: '#/definitions/dto.TenantItem' + type: array + type: object + summary: List creators + tags: + - Creator /super/v1/orders: get: consumes: @@ -2139,6 +2296,43 @@ paths: summary: Order statistics tags: - Order + /super/v1/reports/export: + post: + consumes: + - application/json + description: Export platform report data + parameters: + - description: Export form + in: body + name: form + required: true + schema: + $ref: '#/definitions/dto.SuperReportExportForm' + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/dto.ReportExportResponse' + summary: Export report + tags: + - Report + /super/v1/reports/overview: + get: + consumes: + - application/json + description: Get platform report overview + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/dto.ReportOverviewResponse' + summary: Report overview + tags: + - Report /super/v1/tenants: get: consumes: @@ -2612,6 +2806,87 @@ paths: summary: User statuses tags: - User + /super/v1/withdrawals: + get: + consumes: + - application/json + description: List withdrawal orders across tenants + parameters: + - description: Page number + in: query + name: page + type: integer + - description: Page size + in: query + name: limit + type: integer + produces: + - application/json + responses: + "200": + description: OK + schema: + allOf: + - $ref: '#/definitions/requests.Pager' + - properties: + items: + items: + $ref: '#/definitions/dto.SuperOrderItem' + type: array + type: object + summary: List withdrawals + tags: + - Finance + /super/v1/withdrawals/{id}/approve: + post: + consumes: + - application/json + description: Approve a withdrawal request + parameters: + - description: Withdrawal order ID + format: int64 + in: path + name: id + required: true + type: integer + produces: + - application/json + responses: + "200": + description: Approved + schema: + type: string + summary: Approve withdrawal + tags: + - Finance + /super/v1/withdrawals/{id}/reject: + post: + consumes: + - application/json + description: Reject a withdrawal request + parameters: + - description: Withdrawal order ID + format: int64 + in: path + name: id + required: true + type: integer + - description: Reject form + in: body + name: form + required: true + schema: + $ref: '#/definitions/dto.SuperWithdrawalRejectForm' + produces: + - application/json + responses: + "200": + description: Rejected + schema: + type: string + summary: Reject withdrawal + tags: + - Finance /t/{tenantCode}/v1/auth/login: post: consumes: diff --git a/frontend/superadmin/src/service/CouponService.js b/frontend/superadmin/src/service/CouponService.js new file mode 100644 index 0000000..2369a50 --- /dev/null +++ b/frontend/superadmin/src/service/CouponService.js @@ -0,0 +1,44 @@ +import { requestJson } from './apiClient'; + +function normalizeItems(items) { + if (Array.isArray(items)) return items; + if (items && typeof items === 'object') return [items]; + return []; +} + +export const CouponService = { + async listCoupons({ page, limit, id, tenant_id, tenant_code, tenant_name, keyword, type, status, created_at_from, created_at_to, 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, + keyword, + type, + status, + created_at_from: iso(created_at_from), + created_at_to: iso(created_at_to) + }; + if (sortField && sortOrder) { + if (sortOrder === 1) query.asc = sortField; + if (sortOrder === -1) query.desc = sortField; + } + + const data = await requestJson('/super/v1/coupons', { query }); + return { + page: data?.page ?? page ?? 1, + limit: data?.limit ?? limit ?? 10, + total: data?.total ?? 0, + items: normalizeItems(data?.items) + }; + } +}; diff --git a/frontend/superadmin/src/service/CreatorService.js b/frontend/superadmin/src/service/CreatorService.js new file mode 100644 index 0000000..0e78415 --- /dev/null +++ b/frontend/superadmin/src/service/CreatorService.js @@ -0,0 +1,42 @@ +import { requestJson } from './apiClient'; + +function normalizeItems(items) { + if (Array.isArray(items)) return items; + if (items && typeof items === 'object') return [items]; + return []; +} + +export const CreatorService = { + async listCreators({ page, limit, id, user_id, name, code, status, created_at_from, created_at_to, 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, + user_id, + name, + code, + status, + created_at_from: iso(created_at_from), + created_at_to: iso(created_at_to) + }; + if (sortField && sortOrder) { + if (sortOrder === 1) query.asc = sortField; + if (sortOrder === -1) query.desc = sortField; + } + + const data = await requestJson('/super/v1/creators', { query }); + return { + page: data?.page ?? page ?? 1, + limit: data?.limit ?? limit ?? 10, + total: data?.total ?? 0, + items: normalizeItems(data?.items) + }; + } +}; diff --git a/frontend/superadmin/src/service/FinanceService.js b/frontend/superadmin/src/service/FinanceService.js new file mode 100644 index 0000000..f0d480c --- /dev/null +++ b/frontend/superadmin/src/service/FinanceService.js @@ -0,0 +1,59 @@ +import { requestJson } from './apiClient'; + +function normalizeItems(items) { + if (Array.isArray(items)) return items; + if (items && typeof items === 'object') return [items]; + return []; +} + +export const FinanceService = { + async listWithdrawals({ page, limit, id, tenant_id, tenant_code, tenant_name, user_id, username, 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, + 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/withdrawals', { query }); + return { + page: data?.page ?? page ?? 1, + limit: data?.limit ?? limit ?? 10, + total: data?.total ?? 0, + items: normalizeItems(data?.items) + }; + }, + async approveWithdrawal(id) { + if (!id) throw new Error('id is required'); + return requestJson(`/super/v1/withdrawals/${id}/approve`, { method: 'POST' }); + }, + async rejectWithdrawal(id, { reason } = {}) { + if (!id) throw new Error('id is required'); + return requestJson(`/super/v1/withdrawals/${id}/reject`, { + method: 'POST', + body: { reason } + }); + } +}; diff --git a/frontend/superadmin/src/service/ReportService.js b/frontend/superadmin/src/service/ReportService.js new file mode 100644 index 0000000..b14dfad --- /dev/null +++ b/frontend/superadmin/src/service/ReportService.js @@ -0,0 +1,39 @@ +import { requestJson } from './apiClient'; + +export const ReportService = { + async getOverview({ tenant_id, start_at, end_at, granularity } = {}) { + 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 = { + tenant_id, + start_at: iso(start_at), + end_at: iso(end_at), + granularity + }; + return requestJson('/super/v1/reports/overview', { query }); + }, + async exportReport({ tenant_id, start_at, end_at, granularity, format } = {}) { + 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(); + }; + + return requestJson('/super/v1/reports/export', { + method: 'POST', + body: { + tenant_id, + start_at: iso(start_at), + end_at: iso(end_at), + granularity, + format: format || 'csv' + } + }); + } +}; diff --git a/frontend/superadmin/src/views/superadmin/Coupons.vue b/frontend/superadmin/src/views/superadmin/Coupons.vue index 2c570ee..546ec68 100644 --- a/frontend/superadmin/src/views/superadmin/Coupons.vue +++ b/frontend/superadmin/src/views/superadmin/Coupons.vue @@ -1,11 +1,251 @@ diff --git a/frontend/superadmin/src/views/superadmin/Creators.vue b/frontend/superadmin/src/views/superadmin/Creators.vue index a19bf81..f7eb067 100644 --- a/frontend/superadmin/src/views/superadmin/Creators.vue +++ b/frontend/superadmin/src/views/superadmin/Creators.vue @@ -1,18 +1,269 @@ diff --git a/frontend/superadmin/src/views/superadmin/Finance.vue b/frontend/superadmin/src/views/superadmin/Finance.vue index 186ab22..d8e47ac 100644 --- a/frontend/superadmin/src/views/superadmin/Finance.vue +++ b/frontend/superadmin/src/views/superadmin/Finance.vue @@ -1,11 +1,341 @@ diff --git a/frontend/superadmin/src/views/superadmin/Reports.vue b/frontend/superadmin/src/views/superadmin/Reports.vue index 2f28f88..f077f22 100644 --- a/frontend/superadmin/src/views/superadmin/Reports.vue +++ b/frontend/superadmin/src/views/superadmin/Reports.vue @@ -1,11 +1,149 @@