package admin import ( "math" "net/http" "strconv" "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 暂未使用,但在统计中可能作为参考,这里移除未使用的报错 // 2. 核心统计逻辑重构:从关联的订单表获取真实金额 // 使用子查询或 Join 来获取不重复的订单金额,确保即便一次订单对应多次抽奖,金额也不重计 var totalRevenue, orderCount int64 // 统计营收:来自已参与过抽奖(产生过日志)且未退款的订单 (order_status != 4) // 使用 actual_pay_amount (实付金额) queryRevenue := ` SELECT CAST(IFNULL(SUM(distinct_orders.actual_pay_amount), 0) AS SIGNED) as rev, COUNT(*) as cnt FROM ( SELECT DISTINCT o.id, o.actual_pay_amount FROM douyin_orders o JOIN livestream_draw_logs l ON o.id = l.douyin_order_id WHERE l.activity_id = ? ` if startTime != nil { queryRevenue += " AND l.created_at >= '" + startTime.Format("2006-01-02 15:04:05") + "'" } if endTime != nil { queryRevenue += " AND l.created_at <= '" + endTime.Format("2006-01-02 15:04:05") + "'" } queryRevenue += ") as distinct_orders" _ = h.repo.GetDbR().Raw(queryRevenue, id).Row().Scan(&totalRevenue, &orderCount) // 统计退款:来自已参与过抽奖且标记为退款的订单 (order_status = 4) var totalRefund, refundCount int64 queryRefund := ` SELECT CAST(IFNULL(SUM(distinct_orders.actual_pay_amount), 0) AS SIGNED) as ref_amt, COUNT(*) as ref_cnt FROM ( SELECT DISTINCT o.id, o.actual_pay_amount FROM douyin_orders o JOIN livestream_draw_logs l ON o.id = l.douyin_order_id WHERE l.activity_id = ? AND o.order_status = 4 ` if startTime != nil { queryRefund += " AND l.created_at >= '" + startTime.Format("2006-01-02 15:04:05") + "'" } if endTime != nil { queryRefund += " AND l.created_at <= '" + endTime.Format("2006-01-02 15:04:05") + "'" } queryRefund += ") as distinct_orders" _ = h.repo.GetDbR().Raw(queryRefund, id).Row().Scan(&totalRefund, &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 集合,用于过滤成本 refundedShopOrderIDs := make(map[string]bool) var refundedOrders []string qRefundIDs := ` SELECT DISTINCT o.shop_order_id FROM douyin_orders o JOIN livestream_draw_logs l ON o.id = l.douyin_order_id WHERE l.activity_id = ? AND o.order_status = 4 ` if startTime != nil { qRefundIDs += " AND l.created_at >= '" + startTime.Format("2006-01-02 15:04:05") + "'" } if endTime != nil { qRefundIDs += " AND l.created_at <= '" + endTime.Format("2006-01-02 15:04:05") + "'" } h.repo.GetDbR().Raw(qRefundIDs, id).Scan(&refundedOrders) for _, oid := range refundedOrders { refundedShopOrderIDs[oid] = true } // 4. 计算成本(使用 draw_logs.product_id 直接关联 products 表) // 收集未退款订单的 product_id 和对应数量 productIDCountMap := make(map[int64]int64) for _, log := range drawLogs { // 排除已退款的订单 if refundedShopOrderIDs[log.ShopOrderID] { continue } // 使用 draw_logs 中记录的 product_id if log.ProductID > 0 { productIDCountMap[log.ProductID]++ } } var totalCost int64 productCostMap := make(map[int64]int64) if len(productIDCountMap) > 0 { productIDs := make([]int64, 0, len(productIDCountMap)) for pid := range productIDCountMap { productIDs = append(productIDs, pid) } var products []model.Products h.repo.GetDbR().Where("id IN ?", productIDs).Find(&products) for _, p := range products { productCostMap[p.ID] = p.Price } for productID, count := range productIDCountMap { if cost, ok := productCostMap[productID]; ok { totalCost += cost * count } } } // 构建 productID -> cost 映射供每日统计使用 prizeCostMap := productCostMap // 5. 按天分组统计 dailyMap := make(map[string]*dailyLivestreamStats) // 5.1 统计每日营收和退款(直接累加订单实付金额) type DailyAmount struct { DateKey string Amount int64 Count int64 IsRefunded int32 } var dailyAmounts []DailyAmount queryDailyCorrect := ` SELECT date_key, CAST(SUM(actual_pay_amount) AS SIGNED) as amount, COUNT(id) as cnt, refund_flag as is_refunded FROM ( SELECT o.id, DATE_FORMAT(MIN(l.created_at), '%Y-%m-%d') as date_key, o.actual_pay_amount, IF(o.order_status = 4, 1, 0) as refund_flag FROM douyin_orders o JOIN livestream_draw_logs l ON o.id = l.douyin_order_id WHERE l.activity_id = ? ` if startTime != nil { queryDailyCorrect += " AND l.created_at >= '" + startTime.Format("2006-01-02 15:04:05") + "'" } if endTime != nil { queryDailyCorrect += " AND l.created_at <= '" + endTime.Format("2006-01-02 15:04:05") + "'" } queryDailyCorrect += ` GROUP BY o.id ) as t GROUP BY date_key, is_refunded ` rows, _ := h.repo.GetDbR().Raw(queryDailyCorrect, id).Rows() defer rows.Close() for rows.Next() { var da DailyAmount _ = rows.Scan(&da.DateKey, &da.Amount, &da.Count, &da.IsRefunded) dailyAmounts = append(dailyAmounts, da) } for _, da := range dailyAmounts { if _, ok := dailyMap[da.DateKey]; !ok { dailyMap[da.DateKey] = &dailyLivestreamStats{Date: da.DateKey} } // 修正口径:营收(Revenue) = 总流水 (含退款与未退款) // 这样下面的 NetProfit = TotalRevenue - TotalRefund - TotalCost 才不会双重扣除 dailyMap[da.DateKey].TotalRevenue += da.Amount dailyMap[da.DateKey].OrderCount += da.Count if da.IsRefunded == 1 { dailyMap[da.DateKey].TotalRefund += da.Amount dailyMap[da.DateKey].RefundCount += da.Count } } // 5.2 统计每日成本(基于 Logs 的 ProductID) for _, log := range drawLogs { // 排除退款订单 if refundedShopOrderIDs[log.ShopOrderID] { continue } if log.ProductID <= 0 { continue } dateKey := log.CreatedAt.Format("2006-01-02") ds := dailyMap[dateKey] if ds != nil { if cost, ok := prizeCostMap[log.ProductID]; ok { ds.TotalCost += cost } } } // 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, }) } }