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) } // 内容规模与互动指标。 contentCount, err := s.contentCount(ctx, tid) if err != nil { return nil, err } contentCreated, err := s.contentCreatedAggregate(ctx, tid, rg) if err != nil { return nil, err } likeActions, err := s.contentActionAggregate(ctx, tid, consts.UserContentActionTypeLike, rg) if err != nil { return nil, err } favoriteActions, err := s.contentActionAggregate(ctx, tid, consts.UserContentActionTypeFavorite, rg) if err != nil { return nil, err } commentCount, err := s.commentAggregate(ctx, tid, rg) if err != nil { return nil, err } // 订单仅统计内容购买类型,并按状态划分已支付/已退款。 paidCount, paidAmount, err := s.orderAggregate(ctx, tid, consts.OrderTypeContentPurchase, consts.OrderStatusPaid, "paid_at", rg) if err != nil { return nil, err } refundCount, refundAmount, err := s.orderAggregate(ctx, tid, consts.OrderTypeContentPurchase, consts.OrderStatusRefunded, "updated_at", rg) if err != nil { return nil, err } // 提现维度统计(申请/完成/失败)。 withdrawApplyCount, withdrawApplyAmount, err := s.orderAggregate(ctx, tid, consts.OrderTypeWithdrawal, consts.OrderStatusCreated, "created_at", rg) if err != nil { return nil, err } withdrawPaidCount, withdrawPaidAmount, err := s.orderAggregate(ctx, tid, consts.OrderTypeWithdrawal, consts.OrderStatusPaid, "paid_at", rg) if err != nil { return nil, err } withdrawFailedCount, withdrawFailedAmount, err := s.orderAggregate(ctx, tid, consts.OrderTypeWithdrawal, consts.OrderStatusFailed, "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.OrderTypeContentPurchase, consts.OrderStatusPaid, "paid_at", rg) if err != nil { return nil, err } refundSeries, err := s.orderSeries(ctx, tid, consts.OrderTypeContentPurchase, consts.OrderStatusRefunded, "updated_at", rg) if err != nil { return nil, err } withdrawApplySeries, err := s.orderSeries(ctx, tid, consts.OrderTypeWithdrawal, consts.OrderStatusCreated, "created_at", rg) if err != nil { return nil, err } withdrawPaidSeries, err := s.orderSeries(ctx, tid, consts.OrderTypeWithdrawal, consts.OrderStatusPaid, "paid_at", rg) if err != nil { return nil, err } withdrawFailedSeries, err := s.orderSeries(ctx, tid, consts.OrderTypeWithdrawal, consts.OrderStatusFailed, "updated_at", rg) if err != nil { return nil, err } contentCreatedSeries, err := s.contentCreatedSeries(ctx, tid, rg) if err != nil { return nil, err } likeSeries, err := s.contentActionSeries(ctx, tid, consts.UserContentActionTypeLike, rg) if err != nil { return nil, err } favoriteSeries, err := s.contentActionSeries(ctx, tid, consts.UserContentActionTypeFavorite, rg) if err != nil { return nil, err } commentSeries, err := s.commentSeries(ctx, tid, 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] withdrawApplyItem := withdrawApplySeries[key] withdrawPaidItem := withdrawPaidSeries[key] withdrawFailedItem := withdrawFailedSeries[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, WithdrawalApplyOrders: withdrawApplyItem.Count, WithdrawalApplyAmount: float64(withdrawApplyItem.Amount) / 100.0, WithdrawalPaidOrders: withdrawPaidItem.Count, WithdrawalPaidAmount: float64(withdrawPaidItem.Amount) / 100.0, WithdrawalFailedOrders: withdrawFailedItem.Count, WithdrawalFailedAmount: float64(withdrawFailedItem.Amount) / 100.0, ContentCreated: contentCreatedSeries[key], LikeActions: likeSeries[key], FavoriteActions: favoriteSeries[key], CommentCount: commentSeries[key], }) } return &creator_dto.ReportOverviewResponse{ Summary: creator_dto.ReportSummary{ TotalViews: totalViews, ContentCount: contentCount, ContentCreated: contentCreated, LikeActions: likeActions, FavoriteActions: favoriteActions, CommentCount: commentCount, PaidOrders: paidCount, PaidAmount: float64(paidAmount) / 100.0, RefundOrders: refundCount, RefundAmount: float64(refundAmount) / 100.0, WithdrawalApplyOrders: withdrawApplyCount, WithdrawalApplyAmount: float64(withdrawApplyAmount) / 100.0, WithdrawalPaidOrders: withdrawPaidCount, WithdrawalPaidAmount: float64(withdrawPaidAmount) / 100.0, WithdrawalFailedOrders: withdrawFailedCount, WithdrawalFailedAmount: float64(withdrawFailedAmount) / 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,withdrawal_apply_orders,withdrawal_apply_amount,withdrawal_paid_orders,withdrawal_paid_amount,withdrawal_failed_orders,withdrawal_failed_amount,content_created,like_actions,favorite_actions,comment_count\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(",") builder.WriteString(strconv.FormatInt(item.WithdrawalApplyOrders, 10)) builder.WriteString(",") builder.WriteString(formatAmount(item.WithdrawalApplyAmount)) builder.WriteString(",") builder.WriteString(strconv.FormatInt(item.WithdrawalPaidOrders, 10)) builder.WriteString(",") builder.WriteString(formatAmount(item.WithdrawalPaidAmount)) builder.WriteString(",") builder.WriteString(strconv.FormatInt(item.WithdrawalFailedOrders, 10)) builder.WriteString(",") builder.WriteString(formatAmount(item.WithdrawalFailedAmount)) builder.WriteString(",") builder.WriteString(strconv.FormatInt(item.ContentCreated, 10)) builder.WriteString(",") builder.WriteString(strconv.FormatInt(item.LikeActions, 10)) builder.WriteString(",") builder.WriteString(strconv.FormatInt(item.FavoriteActions, 10)) builder.WriteString(",") builder.WriteString(strconv.FormatInt(item.CommentCount, 10)) 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, orderType consts.OrderType, 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, orderType, 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, orderType consts.OrderType, 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, orderType, 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 } type reportCountRow struct { Day time.Time `gorm:"column:day"` Count int64 `gorm:"column:count"` } func (s *creator) contentCount(ctx context.Context, tenantID int64) (int64, error) { tbl, q := models.ContentQuery.QueryContext(ctx) total, err := q.Where(tbl.TenantID.Eq(tenantID)).Count() if err != nil { return 0, errorx.ErrDatabaseError.WithCause(err) } return total, nil } func (s *creator) contentCreatedAggregate(ctx context.Context, tenantID int64, rg reportRange) (int64, error) { tbl, q := models.ContentQuery.QueryContext(ctx) total, err := q.Where( tbl.TenantID.Eq(tenantID), tbl.CreatedAt.Gte(rg.startDay), tbl.CreatedAt.Lt(rg.endNext), ).Count() if err != nil { return 0, errorx.ErrDatabaseError.WithCause(err) } return total, nil } func (s *creator) contentCreatedSeries(ctx context.Context, tenantID int64, rg reportRange) (map[string]int64, error) { rows := make([]reportCountRow, 0) err := models.ContentQuery.WithContext(ctx). UnderlyingDB(). Model(&models.Content{}). Select("date_trunc('day', created_at) as day, count(*) as count"). Where("tenant_id = ? AND created_at >= ? AND created_at < ?", tenantID, rg.startDay, rg.endNext). Group("day"). Scan(&rows).Error if err != nil { return nil, errorx.ErrDatabaseError.WithCause(err) } return buildCountSeries(rows), nil } func (s *creator) contentActionAggregate( ctx context.Context, tenantID int64, actionType consts.UserContentActionType, rg reportRange, ) (int64, error) { var total int64 query := models.UserContentActionQuery.WithContext(ctx). UnderlyingDB(). Model(&models.UserContentAction{}). Select("count(*)"). Joins("join contents on contents.id = user_content_actions.content_id"). Where("user_content_actions.type = ? AND user_content_actions.created_at >= ? AND user_content_actions.created_at < ? AND contents.tenant_id = ?", actionType, rg.startDay, rg.endNext, tenantID) if err := query.Scan(&total).Error; err != nil { return 0, errorx.ErrDatabaseError.WithCause(err) } return total, nil } func (s *creator) contentActionSeries( ctx context.Context, tenantID int64, actionType consts.UserContentActionType, rg reportRange, ) (map[string]int64, error) { rows := make([]reportCountRow, 0) err := models.UserContentActionQuery.WithContext(ctx). UnderlyingDB(). Model(&models.UserContentAction{}). Select("date_trunc('day', user_content_actions.created_at) as day, count(*) as count"). Joins("join contents on contents.id = user_content_actions.content_id"). Where("user_content_actions.type = ? AND user_content_actions.created_at >= ? AND user_content_actions.created_at < ? AND contents.tenant_id = ?", actionType, rg.startDay, rg.endNext, tenantID). Group("day"). Scan(&rows).Error if err != nil { return nil, errorx.ErrDatabaseError.WithCause(err) } return buildCountSeries(rows), nil } func (s *creator) commentAggregate(ctx context.Context, tenantID int64, rg reportRange) (int64, error) { tbl, q := models.CommentQuery.QueryContext(ctx) total, err := q.Where( tbl.TenantID.Eq(tenantID), tbl.CreatedAt.Gte(rg.startDay), tbl.CreatedAt.Lt(rg.endNext), ).Count() if err != nil { return 0, errorx.ErrDatabaseError.WithCause(err) } return total, nil } func (s *creator) commentSeries(ctx context.Context, tenantID int64, rg reportRange) (map[string]int64, error) { rows := make([]reportCountRow, 0) err := models.CommentQuery.WithContext(ctx). UnderlyingDB(). Model(&models.Comment{}). Select("date_trunc('day', created_at) as day, count(*) as count"). Where("tenant_id = ? AND created_at >= ? AND created_at < ?", tenantID, rg.startDay, rg.endNext). Group("day"). Scan(&rows).Error if err != nil { return nil, errorx.ErrDatabaseError.WithCause(err) } return buildCountSeries(rows), nil } func buildCountSeries(rows []reportCountRow) map[string]int64 { result := make(map[string]int64, len(rows)) for _, row := range rows { key := row.Day.Format("2006-01-02") result[key] = row.Count } return result } 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) }