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)
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"quyun/v2/app/commands/testx"
|
||||
creator_dto "quyun/v2/app/http/v1/dto"
|
||||
@@ -384,3 +385,128 @@ func (s *CreatorTestSuite) Test_Refund() {
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
func (s *CreatorTestSuite) Test_ReportOverview() {
|
||||
Convey("ReportOverview", s.T(), func() {
|
||||
ctx := s.T().Context()
|
||||
database.Truncate(ctx, s.DB,
|
||||
models.TableNameTenant,
|
||||
models.TableNameUser,
|
||||
models.TableNameContent,
|
||||
models.TableNameOrder,
|
||||
)
|
||||
|
||||
owner := &models.User{Username: "owner_r", Phone: "13900001011"}
|
||||
models.UserQuery.WithContext(ctx).Create(owner)
|
||||
tenant := &models.Tenant{
|
||||
Name: "Tenant Report",
|
||||
UserID: owner.ID,
|
||||
Status: consts.TenantStatusVerified,
|
||||
}
|
||||
models.TenantQuery.WithContext(ctx).Create(tenant)
|
||||
|
||||
models.ContentQuery.WithContext(ctx).Create(&models.Content{
|
||||
TenantID: tenant.ID,
|
||||
UserID: owner.ID,
|
||||
Title: "Content A",
|
||||
Status: consts.ContentStatusPublished,
|
||||
Views: 100,
|
||||
})
|
||||
|
||||
now := time.Now()
|
||||
inRangePaidAt := now.Add(-12 * time.Hour)
|
||||
outRangePaidAt := now.Add(-10 * 24 * time.Hour)
|
||||
|
||||
models.OrderQuery.WithContext(ctx).Create(
|
||||
&models.Order{
|
||||
TenantID: tenant.ID,
|
||||
UserID: owner.ID,
|
||||
Type: consts.OrderTypeContentPurchase,
|
||||
Status: consts.OrderStatusPaid,
|
||||
AmountPaid: 1000,
|
||||
PaidAt: inRangePaidAt,
|
||||
},
|
||||
&models.Order{
|
||||
TenantID: tenant.ID,
|
||||
UserID: owner.ID,
|
||||
Type: consts.OrderTypeContentPurchase,
|
||||
Status: consts.OrderStatusPaid,
|
||||
AmountPaid: 2000,
|
||||
PaidAt: outRangePaidAt,
|
||||
},
|
||||
&models.Order{
|
||||
TenantID: tenant.ID,
|
||||
UserID: owner.ID,
|
||||
Type: consts.OrderTypeContentPurchase,
|
||||
Status: consts.OrderStatusRefunded,
|
||||
AmountPaid: 500,
|
||||
UpdatedAt: now.Add(-6 * time.Hour),
|
||||
},
|
||||
)
|
||||
|
||||
start := now.Add(-24 * time.Hour).Format(time.RFC3339)
|
||||
end := now.Format(time.RFC3339)
|
||||
report, err := Creator.ReportOverview(ctx, tenant.ID, owner.ID, &creator_dto.ReportOverviewFilter{
|
||||
StartAt: &start,
|
||||
EndAt: &end,
|
||||
})
|
||||
So(err, ShouldBeNil)
|
||||
So(report.Summary.TotalViews, ShouldEqual, 100)
|
||||
So(report.Summary.PaidOrders, ShouldEqual, 1)
|
||||
So(report.Summary.PaidAmount, ShouldEqual, 10.0)
|
||||
So(report.Summary.RefundOrders, ShouldEqual, 1)
|
||||
So(report.Summary.RefundAmount, ShouldEqual, 5.0)
|
||||
|
||||
var paidSum, refundSum int64
|
||||
for _, item := range report.Items {
|
||||
paidSum += item.PaidOrders
|
||||
refundSum += item.RefundOrders
|
||||
}
|
||||
So(paidSum, ShouldEqual, report.Summary.PaidOrders)
|
||||
So(refundSum, ShouldEqual, report.Summary.RefundOrders)
|
||||
})
|
||||
}
|
||||
|
||||
func (s *CreatorTestSuite) Test_ExportReport() {
|
||||
Convey("ExportReport", s.T(), func() {
|
||||
ctx := s.T().Context()
|
||||
database.Truncate(ctx, s.DB,
|
||||
models.TableNameTenant,
|
||||
models.TableNameUser,
|
||||
models.TableNameContent,
|
||||
models.TableNameOrder,
|
||||
)
|
||||
|
||||
owner := &models.User{Username: "owner_e", Phone: "13900001012"}
|
||||
models.UserQuery.WithContext(ctx).Create(owner)
|
||||
tenant := &models.Tenant{
|
||||
Name: "Tenant Export",
|
||||
UserID: owner.ID,
|
||||
Status: consts.TenantStatusVerified,
|
||||
}
|
||||
models.TenantQuery.WithContext(ctx).Create(tenant)
|
||||
|
||||
models.ContentQuery.WithContext(ctx).Create(&models.Content{
|
||||
TenantID: tenant.ID,
|
||||
UserID: owner.ID,
|
||||
Title: "Content Export",
|
||||
Status: consts.ContentStatusPublished,
|
||||
Views: 10,
|
||||
})
|
||||
|
||||
models.OrderQuery.WithContext(ctx).Create(&models.Order{
|
||||
TenantID: tenant.ID,
|
||||
UserID: owner.ID,
|
||||
Type: consts.OrderTypeContentPurchase,
|
||||
Status: consts.OrderStatusPaid,
|
||||
AmountPaid: 1200,
|
||||
PaidAt: time.Now().Add(-2 * time.Hour),
|
||||
})
|
||||
|
||||
form := &creator_dto.ReportExportForm{Format: "csv"}
|
||||
resp, err := Creator.ExportReport(ctx, tenant.ID, owner.ID, form)
|
||||
So(err, ShouldBeNil)
|
||||
So(resp.Filename, ShouldNotBeBlank)
|
||||
So(resp.Content, ShouldContainSubstring, "date,paid_orders,paid_amount,refund_orders,refund_amount")
|
||||
})
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user