Files
quyun-v2/backend/app/services/creator_report.go

500 lines
16 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)
}
// 内容规模与互动指标。
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)
}