package admin import ( "math" "net/http" "strconv" "strings" "bindbox-game/internal/code" "bindbox-game/internal/pkg/core" "bindbox-game/internal/repository/mysql/model" "time" ) type dailyLivestreamStats struct { Date string `json:"date"` // 日期 TotalRevenue int64 `json:"total_revenue"` // 营收 TotalRefund int64 `json:"total_refund"` // 退款 TotalCost int64 `json:"total_cost"` // 成本 NetProfit int64 `json:"net_profit"` // 净利润 ProfitMargin float64 `json:"profit_margin"` // 利润率 OrderCount int64 `json:"order_count"` // 订单数 RefundCount int64 `json:"refund_count"` // 退款单数 } type livestreamStatsResponse struct { TotalRevenue int64 `json:"total_revenue"` // 总营收(分) TotalRefund int64 `json:"total_refund"` // 总退款(分) TotalCost int64 `json:"total_cost"` // 总成本(分) NetProfit int64 `json:"net_profit"` // 净利润(分) OrderCount int64 `json:"order_count"` // 订单数 RefundCount int64 `json:"refund_count"` // 退款数 ProfitMargin float64 `json:"profit_margin"` // 利润率 % Daily []dailyLivestreamStats `json:"daily"` // 每日明细 } // GetLivestreamStats 获取直播间盈亏统计 // @Summary 获取直播间盈亏统计 // @Description 计算逻辑:净利润 = (营收 - 退款) - 奖品成本。营收 = 抽奖次数 * 门票价格。成本 = 中奖奖品成本总和。 // @Tags 管理端.直播间 // @Accept json // @Produce json // @Param id path integer true "活动ID" // @Success 200 {object} livestreamStatsResponse // @Failure 400 {object} code.Failure // @Router /api/admin/livestream/activities/{id}/stats [get] // @Security LoginVerifyToken func (h *handler) GetLivestreamStats() core.HandlerFunc { return func(ctx core.Context) { id, err := strconv.ParseInt(ctx.Param("id"), 10, 64) if err != nil || id <= 0 { ctx.AbortWithError(core.Error(http.StatusBadRequest, code.ParamBindError, "无效的活动ID")) return } req := new(struct { StartTime string `form:"start_time"` EndTime string `form:"end_time"` }) _ = ctx.ShouldBindQuery(req) var startTime, endTime *time.Time if req.StartTime != "" { if t, err := time.ParseInLocation("2006-01-02", req.StartTime, time.Local); err == nil { startTime = &t } } if req.EndTime != "" { if t, err := time.ParseInLocation("2006-01-02", req.EndTime, time.Local); err == nil { end := t.Add(24*time.Hour - time.Nanosecond) endTime = &end } } // 1. 获取活动信息(门票价格) var activity model.LivestreamActivities if err := h.repo.GetDbR().Where("id = ?", id).First(&activity).Error; err != nil { ctx.AbortWithError(core.Error(http.StatusNotFound, code.ServerError, "活动不存在")) return } ticketPrice := int64(activity.TicketPrice) // 2. 统计营收/退款:基于订单去重并兼容次卡(0元订单按门票价计入) type orderRef struct { OrderID int64 FirstDrawAt time.Time } orderQuery := h.repo.GetDbR().Table(model.TableNameLivestreamDrawLogs). Select("douyin_order_id AS order_id, MIN(created_at) AS first_draw_at"). Where("activity_id = ?", id). Where("douyin_order_id > 0") if startTime != nil { orderQuery = orderQuery.Where("created_at >= ?", startTime) } if endTime != nil { orderQuery = orderQuery.Where("created_at <= ?", endTime) } var orderRefs []orderRef if err := orderQuery.Group("douyin_order_id").Scan(&orderRefs).Error; err != nil { ctx.AbortWithError(core.Error(http.StatusInternalServerError, code.ServerError, err.Error())) return } orderIDs := make([]int64, 0, len(orderRefs)) for _, ref := range orderRefs { if ref.OrderID == 0 { continue } orderIDs = append(orderIDs, ref.OrderID) } orderMap := make(map[int64]*model.DouyinOrders, len(orderIDs)) if len(orderIDs) > 0 { 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 { ctx.AbortWithError(core.Error(http.StatusInternalServerError, code.ServerError, err.Error())) return } for i := range orders { orderMap[orders[i].ID] = &orders[i] } } dailyMap := make(map[string]*dailyLivestreamStats) refundedShopOrderIDs := make(map[string]bool) var totalRevenue, totalRefund int64 var orderCount, refundCount int64 for _, ref := range orderRefs { order := orderMap[ref.OrderID] if order == nil { continue } amount := calcLivestreamOrderAmount(order, ticketPrice) if amount < 0 { amount = 0 } dateKey := ref.FirstDrawAt.In(time.Local).Format("2006-01-02") if ref.FirstDrawAt.IsZero() { dateKey = time.Now().In(time.Local).Format("2006-01-02") } refunded := order.OrderStatus == 4 orderCount++ totalRevenue += amount if refunded { totalRefund += amount refundCount++ } if refunded && order.ShopOrderID != "" { refundedShopOrderIDs[order.ShopOrderID] = true } ds := dailyMap[dateKey] if ds == nil { ds = &dailyLivestreamStats{Date: dateKey} dailyMap[dateKey] = ds } ds.TotalRevenue += amount ds.OrderCount++ if refunded { ds.TotalRefund += amount ds.RefundCount++ } } // 3. 获取所有抽奖记录用于成本计算 var drawLogs []model.LivestreamDrawLogs db := h.repo.GetDbR().Where("activity_id = ?", id) if startTime != nil { db = db.Where("created_at >= ?", startTime) } if endTime != nil { db = db.Where("created_at <= ?", endTime) } db.Find(&drawLogs) // 3.1 获取该时间段内所有退款的 shop_order_id 集合,用于过滤成本 // 4. 计算成本(优先资产快照 user_inventory.value_cents,缺失回退 livestream_prizes.cost_price) prizeCostMap := make(map[int64]int64) prizeIDs := make([]int64, 0) prizeIDSet := make(map[int64]struct{}) userIDSet := make(map[int64]struct{}) for _, log := range drawLogs { if log.PrizeID > 0 { if _, ok := prizeIDSet[log.PrizeID]; !ok { prizeIDSet[log.PrizeID] = struct{}{} prizeIDs = append(prizeIDs, log.PrizeID) } } if log.LocalUserID > 0 { userIDSet[log.LocalUserID] = struct{}{} } } if len(prizeIDs) > 0 { var prizes []model.LivestreamPrizes h.repo.GetDbR().Select("id, cost_price").Where("id IN ?", prizeIDs).Find(&prizes) for _, p := range prizes { prizeCostMap[p.ID] = p.CostPrice } } type inventorySnapshot struct { UserID int64 ValueCents int64 Remark string CreatedAt time.Time } invByUser := make(map[int64][]inventorySnapshot) if len(userIDSet) > 0 { userIDs := make([]int64, 0, len(userIDSet)) for uid := range userIDSet { userIDs = append(userIDs, uid) } var inventories []inventorySnapshot invDB := h.repo.GetDbR().Table("user_inventory"). Select("user_inventory.user_id, COALESCE(NULLIF(user_inventory.value_cents, 0), activity_reward_settings.price_snapshot_cents, products.price, 0) as value_cents, user_inventory.remark, user_inventory.created_at"). Joins("LEFT JOIN activity_reward_settings ON activity_reward_settings.id = user_inventory.reward_id"). Joins("LEFT JOIN products ON products.id = user_inventory.product_id"). Where("user_id IN ?", userIDs). Where("status IN (1, 3)"). Where("COALESCE(user_inventory.remark, '') NOT LIKE ?", "%void%") if startTime != nil { invDB = invDB.Where("created_at >= ?", startTime.Add(-2*time.Hour)) } if endTime != nil { invDB = invDB.Where("created_at <= ?", endTime.Add(24*time.Hour)) } _ = invDB.Scan(&inventories).Error for _, inv := range inventories { invByUser[inv.UserID] = append(invByUser[inv.UserID], inv) } } type logRef struct { PrizeID int64 DateKey string } logsByKey := make(map[string][]logRef) keyUser := make(map[string]int64) keyOrder := make(map[string]string) for _, log := range drawLogs { if refundedShopOrderIDs[log.ShopOrderID] { continue } key := strconv.FormatInt(log.LocalUserID, 10) + "|" + log.ShopOrderID logsByKey[key] = append(logsByKey[key], logRef{ PrizeID: log.PrizeID, DateKey: log.CreatedAt.Format("2006-01-02"), }) keyUser[key] = log.LocalUserID keyOrder[key] = log.ShopOrderID } costByDate := make(map[string]int64) var totalCost int64 for key, refs := range logsByKey { if len(refs) == 0 { continue } uid := keyUser[key] shopOrderID := keyOrder[key] var snapshotSum int64 if uid > 0 && shopOrderID != "" { for _, inv := range invByUser[uid] { if strings.Contains(inv.Remark, shopOrderID) { snapshotSum += inv.ValueCents } } } if snapshotSum > 0 { avg := snapshotSum / int64(len(refs)) rem := snapshotSum - avg*int64(len(refs)) for i, r := range refs { c := avg if i == 0 { c += rem } totalCost += c costByDate[r.DateKey] += c } continue } for _, r := range refs { c := prizeCostMap[r.PrizeID] totalCost += c costByDate[r.DateKey] += c } } // 5. 按天分组统计 (营收/退款已在 dailyMap 中累计),补充成本 for dateKey, c := range costByDate { ds := dailyMap[dateKey] if ds == nil { ds = &dailyLivestreamStats{Date: dateKey} dailyMap[dateKey] = ds } ds.TotalCost += c } // 6. 汇总每日数据并计算总体指标 var calcTotalRevenue, calcTotalRefund, calcTotalCost int64 dailyList := make([]dailyLivestreamStats, 0, len(dailyMap)) for _, ds := range dailyMap { ds.NetProfit = (ds.TotalRevenue - ds.TotalRefund) - ds.TotalCost netRev := ds.TotalRevenue - ds.TotalRefund if netRev > 0 { ds.ProfitMargin = math.Trunc(float64(ds.NetProfit)/float64(netRev)*10000) / 100 } else if netRev == 0 && ds.TotalCost > 0 { ds.ProfitMargin = -100 } dailyList = append(dailyList, *ds) calcTotalRevenue += ds.TotalRevenue calcTotalRefund += ds.TotalRefund calcTotalCost += ds.TotalCost } netProfit := (totalRevenue - totalRefund) - totalCost var margin float64 netRevenue := totalRevenue - totalRefund if netRevenue > 0 { margin = float64(netProfit) / float64(netRevenue) * 100 } else if netRevenue == 0 && totalCost > 0 { margin = -100 } else { margin = 0 } ctx.Payload(&livestreamStatsResponse{ TotalRevenue: totalRevenue, TotalRefund: totalRefund, TotalCost: totalCost, NetProfit: netProfit, OrderCount: orderCount, RefundCount: refundCount, ProfitMargin: math.Trunc(margin*100) / 100, Daily: dailyList, }) } }