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 }