257 lines
7.4 KiB
Go
257 lines
7.4 KiB
Go
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)
|
|
}
|