bindbox-game/internal/api/admin/livestream_metrics.go
Zuncle c927f46cdd fix(livestream): 统一直播间盈亏成本口径并消除转赠影响
将直播间统计从基于 user_inventory 当前持有状态和 remark 反推成本,改为基于 livestream_draw_logs 中奖事实直接关联 products.cost_price 计算成本。统一 /livestream/activities/:id/stats 与 /livestream/activities/:id/draw_logs 两个接口的营收、退款、成本和净利润口径,避免因转赠、remark 覆盖或订单行缺失导致统计失真,并补充针对转赠、退款、零订单和 product 回退场景的回归测试。
2026-04-12 21:23:36 +08:00

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
}