feat: wire superadmin p1 data

This commit is contained in:
2026-01-15 09:35:16 +08:00
parent bb4c5b39d2
commit 235a216b0c
21 changed files with 3188 additions and 28 deletions

View File

@@ -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)
}

View File

@@ -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)
}

View File

@@ -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"`
}

View File

@@ -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"`
}

View File

@@ -0,0 +1,7 @@
package dto
// SuperWithdrawalRejectForm 超管驳回提现表单。
type SuperWithdrawalRejectForm struct {
// Reason 驳回原因。
Reason string `json:"reason" validate:"required"`
}

View File

@@ -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
}

View File

@@ -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)
}

View File

@@ -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<int>/approve -> withdrawals.Approve")
router.Post("/super/v1/withdrawals/:id<int>/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<int>/reject -> withdrawals.Reject")
router.Post("/super/v1/withdrawals/:id<int>/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")
}

View File

@@ -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<int>/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<int>/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)
}

View File

@@ -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("缺少操作者信息")