将直播间统计从基于 user_inventory 当前持有状态和 remark 反推成本,改为基于 livestream_draw_logs 中奖事实直接关联 products.cost_price 计算成本。统一 /livestream/activities/:id/stats 与 /livestream/activities/:id/draw_logs 两个接口的营收、退款、成本和净利润口径,避免因转赠、remark 覆盖或订单行缺失导致统计失真,并补充针对转赠、退款、零订单和 product 回退场景的回归测试。
336 lines
10 KiB
Go
336 lines
10 KiB
Go
package admin
|
|
|
|
import (
|
|
"fmt"
|
|
"math"
|
|
"sort"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
"bindbox-game/internal/repository/mysql/model"
|
|
|
|
"gorm.io/gorm"
|
|
)
|
|
|
|
type livestreamMetricsFilter struct {
|
|
ActivityID int64
|
|
StartTime *time.Time
|
|
EndTime *time.Time
|
|
Keyword string
|
|
ExcludeUserIDs []int64
|
|
}
|
|
|
|
type livestreamMetrics struct {
|
|
UserCount int64
|
|
OrderCount int64
|
|
RefundCount int64
|
|
TotalRevenue int64
|
|
TotalRefund int64
|
|
TotalCost int64
|
|
NetProfit int64
|
|
ProfitMargin float64
|
|
Daily []dailyLivestreamStats
|
|
}
|
|
|
|
type livestreamOrderSummary struct {
|
|
UserCount int64
|
|
OrderCount int64
|
|
RefundCount int64
|
|
TotalRevenue int64
|
|
TotalRefund int64
|
|
RefundedOrderIDs map[int64]bool
|
|
RevenueByDate map[string]int64
|
|
RefundByDate map[string]int64
|
|
OrderCountByDate map[string]int64
|
|
RefundCountByDate map[string]int64
|
|
}
|
|
|
|
type livestreamCostSummary struct {
|
|
TotalCost int64
|
|
CostByDate map[string]int64
|
|
}
|
|
|
|
func (h *handler) buildLivestreamDrawLogScope(db *gorm.DB, f livestreamMetricsFilter) *gorm.DB {
|
|
q := db.Table("livestream_draw_logs AS dl").Where("dl.activity_id = ?", f.ActivityID)
|
|
if f.StartTime != nil {
|
|
q = q.Where("dl.created_at >= ?", f.StartTime)
|
|
}
|
|
if f.EndTime != nil {
|
|
q = q.Where("dl.created_at <= ?", f.EndTime)
|
|
}
|
|
if keyword := strings.TrimSpace(f.Keyword); keyword != "" {
|
|
kw := "%" + keyword + "%"
|
|
q = q.Where("(dl.user_nickname LIKE ? OR dl.shop_order_id LIKE ? OR dl.prize_name LIKE ?)", kw, kw, kw)
|
|
}
|
|
if len(f.ExcludeUserIDs) > 0 {
|
|
q = q.Where("dl.local_user_id NOT IN ?", f.ExcludeUserIDs)
|
|
}
|
|
return q
|
|
}
|
|
|
|
func (h *handler) buildLivestreamMetrics(f livestreamMetricsFilter, ticketPrice int64) (*livestreamMetrics, error) {
|
|
scope := h.buildLivestreamDrawLogScope(h.repo.GetDbR(), f)
|
|
orderSummary, err := h.loadLivestreamOrderSummary(scope, ticketPrice)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
costSummary, err := h.loadLivestreamCostSummary(scope, orderSummary.RefundedOrderIDs)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
dailyMap := make(map[string]*dailyLivestreamStats)
|
|
mergeDailyAmount := func(source map[string]int64, apply func(*dailyLivestreamStats, int64)) {
|
|
for dateKey, value := range source {
|
|
ds := dailyMap[dateKey]
|
|
if ds == nil {
|
|
ds = &dailyLivestreamStats{Date: dateKey}
|
|
dailyMap[dateKey] = ds
|
|
}
|
|
apply(ds, value)
|
|
}
|
|
}
|
|
mergeDailyCount := func(source map[string]int64, apply func(*dailyLivestreamStats, int64)) {
|
|
for dateKey, value := range source {
|
|
ds := dailyMap[dateKey]
|
|
if ds == nil {
|
|
ds = &dailyLivestreamStats{Date: dateKey}
|
|
dailyMap[dateKey] = ds
|
|
}
|
|
apply(ds, value)
|
|
}
|
|
}
|
|
|
|
mergeDailyAmount(orderSummary.RevenueByDate, func(ds *dailyLivestreamStats, v int64) { ds.TotalRevenue += v })
|
|
mergeDailyAmount(orderSummary.RefundByDate, func(ds *dailyLivestreamStats, v int64) { ds.TotalRefund += v })
|
|
mergeDailyAmount(costSummary.CostByDate, func(ds *dailyLivestreamStats, v int64) { ds.TotalCost += v })
|
|
mergeDailyCount(orderSummary.OrderCountByDate, func(ds *dailyLivestreamStats, v int64) { ds.OrderCount += v })
|
|
mergeDailyCount(orderSummary.RefundCountByDate, func(ds *dailyLivestreamStats, v int64) { ds.RefundCount += v })
|
|
|
|
dailyList := make([]dailyLivestreamStats, 0, len(dailyMap))
|
|
for _, ds := range dailyMap {
|
|
ds.NetProfit = (ds.TotalRevenue - ds.TotalRefund) - ds.TotalCost
|
|
netRevenue := ds.TotalRevenue - ds.TotalRefund
|
|
if netRevenue > 0 {
|
|
ds.ProfitMargin = math.Trunc(float64(ds.NetProfit)/float64(netRevenue)*10000) / 100
|
|
} else if netRevenue == 0 && ds.TotalCost > 0 {
|
|
ds.ProfitMargin = -100
|
|
}
|
|
dailyList = append(dailyList, *ds)
|
|
}
|
|
sort.Slice(dailyList, func(i, j int) bool { return dailyList[i].Date > dailyList[j].Date })
|
|
|
|
netProfit := (orderSummary.TotalRevenue - orderSummary.TotalRefund) - costSummary.TotalCost
|
|
netRevenue := orderSummary.TotalRevenue - orderSummary.TotalRefund
|
|
margin := 0.0
|
|
if netRevenue > 0 {
|
|
margin = math.Trunc(float64(netProfit)/float64(netRevenue)*10000) / 100
|
|
} else if netRevenue == 0 && costSummary.TotalCost > 0 {
|
|
margin = -100
|
|
}
|
|
|
|
return &livestreamMetrics{
|
|
UserCount: orderSummary.UserCount,
|
|
OrderCount: orderSummary.OrderCount,
|
|
RefundCount: orderSummary.RefundCount,
|
|
TotalRevenue: orderSummary.TotalRevenue,
|
|
TotalRefund: orderSummary.TotalRefund,
|
|
TotalCost: costSummary.TotalCost,
|
|
NetProfit: netProfit,
|
|
ProfitMargin: margin,
|
|
Daily: dailyList,
|
|
}, nil
|
|
}
|
|
|
|
func (h *handler) countLivestreamUsers(scope *gorm.DB) (int64, error) {
|
|
type userRef struct {
|
|
DouyinUserID string `gorm:"column:douyin_user_id"`
|
|
LocalUserID int64 `gorm:"column:local_user_id"`
|
|
}
|
|
|
|
var refs []userRef
|
|
if err := scope.Session(&gorm.Session{}).
|
|
Select("douyin_user_id, local_user_id").
|
|
Scan(&refs).Error; err != nil {
|
|
return 0, err
|
|
}
|
|
|
|
seen := make(map[string]struct{}, len(refs))
|
|
for _, ref := range refs {
|
|
if id := strings.TrimSpace(ref.DouyinUserID); id != "" {
|
|
seen["dy:"+id] = struct{}{}
|
|
continue
|
|
}
|
|
if ref.LocalUserID > 0 {
|
|
seen[fmt.Sprintf("local:%d", ref.LocalUserID)] = struct{}{}
|
|
}
|
|
}
|
|
return int64(len(seen)), nil
|
|
}
|
|
|
|
func (h *handler) loadLivestreamOrderSummary(scope *gorm.DB, ticketPrice int64) (*livestreamOrderSummary, error) {
|
|
type orderRef struct {
|
|
OrderID int64 `gorm:"column:order_id"`
|
|
FirstDrawAtRaw string `gorm:"column:first_draw_at"`
|
|
}
|
|
var orderRefs []orderRef
|
|
if err := scope.Session(&gorm.Session{}).
|
|
Select("dl.douyin_order_id AS order_id, MIN(dl.created_at) AS first_draw_at").
|
|
Where("dl.douyin_order_id > 0").
|
|
Group("dl.douyin_order_id").
|
|
Scan(&orderRefs).Error; err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
result := &livestreamOrderSummary{
|
|
RefundedOrderIDs: make(map[int64]bool),
|
|
RevenueByDate: make(map[string]int64),
|
|
RefundByDate: make(map[string]int64),
|
|
OrderCountByDate: make(map[string]int64),
|
|
RefundCountByDate: make(map[string]int64),
|
|
}
|
|
|
|
userCount, err := h.countLivestreamUsers(scope)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
result.UserCount = userCount
|
|
|
|
orderIDs := make([]int64, 0, len(orderRefs))
|
|
for _, ref := range orderRefs {
|
|
if ref.OrderID > 0 {
|
|
orderIDs = append(orderIDs, ref.OrderID)
|
|
}
|
|
}
|
|
if len(orderIDs) == 0 {
|
|
return result, nil
|
|
}
|
|
|
|
var orders []model.DouyinOrders
|
|
if err := h.repo.GetDbR().
|
|
Select("id, shop_order_id, actual_pay_amount, order_status, pay_type_desc, product_count").
|
|
Where("id IN ?", orderIDs).
|
|
Find(&orders).Error; err != nil {
|
|
return nil, err
|
|
}
|
|
orderMap := make(map[int64]*model.DouyinOrders, len(orders))
|
|
for i := range orders {
|
|
orderMap[orders[i].ID] = &orders[i]
|
|
}
|
|
|
|
for _, ref := range orderRefs {
|
|
order := orderMap[ref.OrderID]
|
|
if order == nil {
|
|
continue
|
|
}
|
|
amount := calcLivestreamOrderAmount(order, ticketPrice)
|
|
if amount < 0 {
|
|
amount = 0
|
|
}
|
|
dateKey := time.Now().In(time.Local).Format("2006-01-02")
|
|
if ref.FirstDrawAtRaw != "" {
|
|
if parsed, err := time.ParseInLocation("2006-01-02 15:04:05.999", ref.FirstDrawAtRaw, time.Local); err == nil {
|
|
dateKey = parsed.In(time.Local).Format("2006-01-02")
|
|
} else if parsed, err := time.ParseInLocation("2006-01-02 15:04:05", ref.FirstDrawAtRaw, time.Local); err == nil {
|
|
dateKey = parsed.In(time.Local).Format("2006-01-02")
|
|
} else if parsed, err := time.ParseInLocation(time.RFC3339Nano, ref.FirstDrawAtRaw, time.Local); err == nil {
|
|
dateKey = parsed.In(time.Local).Format("2006-01-02")
|
|
}
|
|
}
|
|
result.OrderCount++
|
|
result.TotalRevenue += amount
|
|
result.RevenueByDate[dateKey] += amount
|
|
result.OrderCountByDate[dateKey]++
|
|
if order.OrderStatus == 4 {
|
|
result.RefundCount++
|
|
result.TotalRefund += amount
|
|
result.RefundedOrderIDs[order.ID] = true
|
|
result.RefundByDate[dateKey] += amount
|
|
result.RefundCountByDate[dateKey]++
|
|
}
|
|
}
|
|
|
|
return result, nil
|
|
}
|
|
|
|
func (h *handler) loadLivestreamCostSummary(scope *gorm.DB, refundedOrderIDs map[int64]bool) (*livestreamCostSummary, error) {
|
|
type costRow struct {
|
|
DouyinOrderID int64 `gorm:"column:douyin_order_id"`
|
|
CreatedAt time.Time `gorm:"column:created_at"`
|
|
IsRefunded int32 `gorm:"column:is_refunded"`
|
|
UnitCost int64 `gorm:"column:unit_cost"`
|
|
}
|
|
var rows []costRow
|
|
if err := scope.Session(&gorm.Session{}).
|
|
Table("livestream_draw_logs AS dl").
|
|
Select(`
|
|
dl.douyin_order_id,
|
|
dl.created_at,
|
|
dl.is_refunded,
|
|
COALESCE(p.cost_price, 0) AS unit_cost
|
|
`).
|
|
Joins("LEFT JOIN livestream_prizes lp ON lp.id = dl.prize_id").
|
|
Joins("LEFT JOIN products p ON p.id = COALESCE(NULLIF(dl.product_id, 0), lp.product_id)").
|
|
Scan(&rows).Error; err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
result := &livestreamCostSummary{CostByDate: make(map[string]int64)}
|
|
for _, row := range rows {
|
|
if row.IsRefunded == 1 || refundedOrderIDs[row.DouyinOrderID] {
|
|
continue
|
|
}
|
|
result.TotalCost += row.UnitCost
|
|
result.CostByDate[row.CreatedAt.In(time.Local).Format("2006-01-02")] += row.UnitCost
|
|
}
|
|
return result, nil
|
|
}
|
|
|
|
func parseLivestreamDateRange(startRaw, endRaw string, withTime bool) (*time.Time, *time.Time) {
|
|
var startTime, endTime *time.Time
|
|
if startRaw != "" {
|
|
if withTime {
|
|
if t, err := time.ParseInLocation("2006-01-02 15:04:05", startRaw, time.Local); err == nil {
|
|
startTime = &t
|
|
} else if t, err := time.ParseInLocation("2006-01-02", startRaw, time.Local); err == nil {
|
|
startTime = &t
|
|
}
|
|
} else if t, err := time.ParseInLocation("2006-01-02", startRaw, time.Local); err == nil {
|
|
startTime = &t
|
|
}
|
|
}
|
|
if endRaw != "" {
|
|
if withTime {
|
|
if t, err := time.ParseInLocation("2006-01-02 15:04:05", endRaw, time.Local); err == nil {
|
|
endTime = &t
|
|
} else if t, err := time.ParseInLocation("2006-01-02", endRaw, time.Local); err == nil {
|
|
end := t.Add(24*time.Hour - time.Nanosecond)
|
|
endTime = &end
|
|
}
|
|
} else if t, err := time.ParseInLocation("2006-01-02", endRaw, time.Local); err == nil {
|
|
end := t.Add(24*time.Hour - time.Nanosecond)
|
|
endTime = &end
|
|
}
|
|
}
|
|
return startTime, endTime
|
|
}
|
|
|
|
func parseExcludeUserIDs(raw string) []int64 {
|
|
if strings.TrimSpace(raw) == "" {
|
|
return nil
|
|
}
|
|
parts := strings.Split(raw, ",")
|
|
ids := make([]int64, 0, len(parts))
|
|
for _, part := range parts {
|
|
part = strings.TrimSpace(part)
|
|
if part == "" {
|
|
continue
|
|
}
|
|
if id, err := strconv.ParseInt(part, 10, 64); err == nil && id > 0 {
|
|
ids = append(ids, id)
|
|
}
|
|
}
|
|
return ids
|
|
}
|