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