feat: add creator report overview export
This commit is contained in:
256
backend/app/services/creator_report.go
Normal file
256
backend/app/services/creator_report.go
Normal file
@@ -0,0 +1,256 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"quyun/v2/app/errorx"
|
||||
creator_dto "quyun/v2/app/http/v1/dto"
|
||||
"quyun/v2/database/models"
|
||||
"quyun/v2/pkg/consts"
|
||||
)
|
||||
|
||||
type reportRange struct {
|
||||
startDay time.Time
|
||||
endDay time.Time
|
||||
endNext time.Time
|
||||
}
|
||||
|
||||
func (s *creator) ReportOverview(
|
||||
ctx context.Context,
|
||||
tenantID int64,
|
||||
userID int64,
|
||||
filter *creator_dto.ReportOverviewFilter,
|
||||
) (*creator_dto.ReportOverviewResponse, error) {
|
||||
// 校验租户归属(仅租户主可查看统计)。
|
||||
tid, err := s.getTenantID(ctx, tenantID, userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// 统一统计时间范围与粒度。
|
||||
rg, err := s.normalizeReportRange(filter)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// 统计累计曝光(全量累计值,暂无按时间拆分的曝光记录)。
|
||||
var totalViews int64
|
||||
err = models.ContentQuery.WithContext(ctx).
|
||||
UnderlyingDB().
|
||||
Model(&models.Content{}).
|
||||
Select("coalesce(sum(views), 0)").
|
||||
Where("tenant_id = ?", tid).
|
||||
Scan(&totalViews).Error
|
||||
if err != nil {
|
||||
return nil, errorx.ErrDatabaseError.WithCause(err)
|
||||
}
|
||||
|
||||
// 订单仅统计内容购买类型,并按状态划分已支付/已退款。
|
||||
paidCount, paidAmount, err := s.orderAggregate(ctx, tid, consts.OrderStatusPaid, "paid_at", rg)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
refundCount, refundAmount, err := s.orderAggregate(ctx, tid, 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.orderSeries(ctx, tid, consts.OrderStatusPaid, "paid_at", rg)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
refundSeries, err := s.orderSeries(ctx, tid, consts.OrderStatusRefunded, "updated_at", rg)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
items := make([]creator_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, creator_dto.ReportOverviewItem{
|
||||
Date: key,
|
||||
PaidOrders: paidItem.Count,
|
||||
PaidAmount: float64(paidItem.Amount) / 100.0,
|
||||
RefundOrders: refundItem.Count,
|
||||
RefundAmount: float64(refundItem.Amount) / 100.0,
|
||||
})
|
||||
}
|
||||
|
||||
return &creator_dto.ReportOverviewResponse{
|
||||
Summary: creator_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 *creator) ExportReport(
|
||||
ctx context.Context,
|
||||
tenantID int64,
|
||||
userID int64,
|
||||
form *creator_dto.ReportExportForm,
|
||||
) (*creator_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, tenantID, userID, &creator_dto.ReportOverviewFilter{
|
||||
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 := fmt.Sprintf("report_overview_%s.csv", time.Now().Format("20060102_150405"))
|
||||
return &creator_dto.ReportExportResponse{
|
||||
Filename: filename,
|
||||
MimeType: "text/csv",
|
||||
Content: builder.String(),
|
||||
}, nil
|
||||
}
|
||||
|
||||
type reportAggRow struct {
|
||||
Day time.Time `gorm:"column:day"`
|
||||
Count int64 `gorm:"column:count"`
|
||||
Amount int64 `gorm:"column:amount"`
|
||||
}
|
||||
|
||||
func (s *creator) orderAggregate(
|
||||
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"`
|
||||
}
|
||||
err := models.OrderQuery.WithContext(ctx).
|
||||
UnderlyingDB().
|
||||
Model(&models.Order{}).
|
||||
Select("count(*) as count, coalesce(sum(amount_paid), 0) as amount").
|
||||
Where("tenant_id = ? AND type = ? AND status = ? AND "+timeField+" >= ? AND "+timeField+" < ?",
|
||||
tenantID, consts.OrderTypeContentPurchase, status, rg.startDay, rg.endNext).
|
||||
Scan(&total).Error
|
||||
if err != nil {
|
||||
return 0, 0, errorx.ErrDatabaseError.WithCause(err)
|
||||
}
|
||||
return total.Count, total.Amount, nil
|
||||
}
|
||||
|
||||
func (s *creator) orderSeries(
|
||||
ctx context.Context,
|
||||
tenantID int64,
|
||||
status consts.OrderStatus,
|
||||
timeField string,
|
||||
rg reportRange,
|
||||
) (map[string]reportAggRow, error) {
|
||||
rows := make([]reportAggRow, 0)
|
||||
err := 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("tenant_id = ? AND type = ? AND status = ? AND "+timeField+" >= ? AND "+timeField+" < ?",
|
||||
tenantID, consts.OrderTypeContentPurchase, status, rg.startDay, rg.endNext).
|
||||
Group("day").
|
||||
Scan(&rows).Error
|
||||
if 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 *creator) normalizeReportRange(filter *creator_dto.ReportOverviewFilter) (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 formatAmount(amount float64) string {
|
||||
return strconv.FormatFloat(amount, 'f', 2, 64)
|
||||
}
|
||||
Reference in New Issue
Block a user