From 9972427cea3c092494ba7d332b5952c0f1867a46 Mon Sep 17 00:00:00 2001 From: win Date: Tue, 24 Feb 2026 10:02:11 +0800 Subject: [PATCH] fix: treat livestream pass orders as ticket price --- internal/api/admin/dashboard_activity.go | 115 +++++--- internal/api/admin/livestream_admin.go | 238 ++++++++++++++-- internal/api/admin/livestream_helpers.go | 36 +++ internal/api/admin/livestream_stats.go | 346 +++++++++++++---------- 4 files changed, 512 insertions(+), 223 deletions(-) create mode 100644 internal/api/admin/livestream_helpers.go diff --git a/internal/api/admin/dashboard_activity.go b/internal/api/admin/dashboard_activity.go index d155c5c..847f694 100755 --- a/internal/api/admin/dashboard_activity.go +++ b/internal/api/admin/dashboard_activity.go @@ -5,6 +5,7 @@ import ( "bindbox-game/internal/pkg/core" "bindbox-game/internal/pkg/validation" "bindbox-game/internal/repository/mysql/model" + financesvc "bindbox-game/internal/service/finance" "encoding/json" "fmt" "net/http" @@ -23,20 +24,25 @@ type activityProfitLossRequest struct { } type activityProfitLossItem struct { - ActivityID int64 `json:"activity_id"` - ActivityName string `json:"activity_name"` - Status int32 `json:"status"` - DrawCount int64 `json:"draw_count"` - GamePassCount int64 `json:"game_pass_count"` // 次卡抽奖次数 - PaymentCount int64 `json:"payment_count"` // 现金/优惠券抽奖次数 - RefundCount int64 `json:"refund_count"` // 退款/取消抽奖次数 - PlayerCount int64 `json:"player_count"` - TotalRevenue int64 `json:"total_revenue"` // 实际支付金额 (分) - TotalDiscount int64 `json:"total_discount"` // 优惠券抵扣金额 (分) - TotalGamePassValue int64 `json:"total_game_pass_value"` // 次卡价值 (分) - TotalCost int64 `json:"total_cost"` // 奖品标价总和 (分) - Profit int64 `json:"profit"` // (Revenue + Discount + GamePassValue) - Cost - ProfitRate float64 `json:"profit_rate"` // Profit / (Revenue + Discount + GamePassValue) + ActivityID int64 `json:"activity_id"` + ActivityName string `json:"activity_name"` + Status int32 `json:"status"` + DrawCount int64 `json:"draw_count"` + GamePassCount int64 `json:"game_pass_count"` // 次卡抽奖次数 + PaymentCount int64 `json:"payment_count"` // 现金/优惠券抽奖次数 + RefundCount int64 `json:"refund_count"` // 退款/取消抽奖次数 + PlayerCount int64 `json:"player_count"` + TotalRevenue int64 `json:"total_revenue"` // 实际支付金额 (分) + TotalDiscount int64 `json:"total_discount"` // 优惠券抵扣金额 (分) + TotalGamePassValue int64 `json:"total_game_pass_value"` // 次卡价值 (分) + TotalCost int64 `json:"total_cost"` // 奖品标价总和 (分) + SpendingPaidCoupon int64 `json:"spending_paid_coupon"` + SpendingGamePass int64 `json:"spending_game_pass"` + PrizeCostBase int64 `json:"prize_cost_base"` + PrizeCostMultiplier int64 `json:"prize_cost_multiplier"` + PrizeCostFinal int64 `json:"prize_cost_final"` + Profit int64 `json:"profit"` // (Revenue + Discount + GamePassValue) - Cost + ProfitRate float64 `json:"profit_rate"` // Profit / (Revenue + Discount + GamePassValue) } type activityProfitLossResponse struct { @@ -170,14 +176,19 @@ func (h *handler) DashboardActivityProfitLoss() core.HandlerFunc { } var revenueStats []revenueStat - // 修正: 按抽奖次数比例分摊订单金额 (解决多活动订单归因问题) - // 逻辑: 活动分摊收入 = 订单实际金额 * (该活动在该订单中的抽奖次数 / 该订单总抽奖次数) + // 修正: 按抽奖次数比例分摊订单金额,且次卡订单不计入支付+优惠券口径(严格二选一) var err error err = db.Table(model.TableNameOrders). Select(` order_activity_draws.activity_id, - SUM(1.0 * orders.actual_amount * order_activity_draws.draw_count / order_total_draws.total_count) as total_revenue, - SUM(CASE WHEN orders.source_type = 4 OR orders.order_no LIKE 'GP%' THEN 0 ELSE 1.0 * orders.discount_amount * order_activity_draws.draw_count / order_total_draws.total_count END) as total_discount + SUM(CASE WHEN orders.source_type = 4 OR orders.order_no LIKE 'GP%' OR (orders.actual_amount = 0 AND orders.remark LIKE '%use_game_pass%') + THEN 0 + ELSE 1.0 * orders.actual_amount * order_activity_draws.draw_count / order_total_draws.total_count + END) as total_revenue, + SUM(CASE WHEN orders.source_type = 4 OR orders.order_no LIKE 'GP%' OR (orders.actual_amount = 0 AND orders.remark LIKE '%use_game_pass%') + THEN 0 + ELSE 1.0 * orders.discount_amount * order_activity_draws.draw_count / order_total_draws.total_count + END) as total_discount `). // Subquery 1: Calculate draw counts per order per activity (and link to issue->activity) Joins(`JOIN ( @@ -211,21 +222,41 @@ func (h *handler) DashboardActivityProfitLoss() core.HandlerFunc { // 4. 统计成本 (通过 user_inventory 关联 products 和 orders) // 修正:增加关联 orders 表,过滤掉已退款/取消的订单 (status!=2) type costStat struct { - ActivityID int64 - TotalCost int64 + ActivityID int64 + TotalCost int64 + TotalCostBase int64 + AvgMultiplierX10 int64 } var costStats []costStat - db.Table(model.TableNameUserInventory). - Select("user_inventory.activity_id, SUM(user_inventory.value_cents) as total_cost"). + if err := db.Table(model.TableNameUserInventory). + Select(` + COALESCE(NULLIF(user_inventory.activity_id, 0), cost_issues.activity_id) as activity_id, + CAST(SUM(COALESCE(NULLIF(user_inventory.value_cents, 0), activity_reward_settings.price_snapshot_cents, products.price, 0) * GREATEST(COALESCE(system_item_cards.reward_multiplier_x1000, 1000), 1000) / 1000) AS SIGNED) as total_cost, + SUM(COALESCE(NULLIF(user_inventory.value_cents, 0), activity_reward_settings.price_snapshot_cents, products.price, 0)) as total_cost_base, + CAST(COALESCE(AVG(GREATEST(COALESCE(system_item_cards.reward_multiplier_x1000, 1000), 1000) / 100), 10) AS SIGNED) as avg_multiplier_x10 + `). Joins("LEFT JOIN orders ON orders.id = user_inventory.order_id"). - Where("user_inventory.activity_id IN ?", activityIDs). - Where("orders.status = ?", 2). // 仅统计已支付订单产生的成本 - Group("user_inventory.activity_id"). - Scan(&costStats) - - for _, s := range costStats { - if item, ok := activityMap[s.ActivityID]; ok { - item.TotalCost = s.TotalCost + Joins("LEFT JOIN activity_reward_settings ON activity_reward_settings.id = user_inventory.reward_id"). + Joins("LEFT JOIN activity_issues AS cost_issues ON cost_issues.id = activity_reward_settings.issue_id"). + Joins("LEFT JOIN products ON products.id = user_inventory.product_id"). + Joins("LEFT JOIN user_item_cards ON user_item_cards.id = orders.item_card_id"). + Joins("LEFT JOIN system_item_cards ON system_item_cards.id = user_item_cards.card_id"). + Where("COALESCE(NULLIF(user_inventory.activity_id, 0), cost_issues.activity_id) IN ?", activityIDs). + Where("user_inventory.status IN ?", []int{1, 3}). + Where("COALESCE(user_inventory.remark, '') NOT LIKE ?", "%void%"). + // 兼容历史数据:部分老资产可能未写入 order_id,避免被 JOIN 条件整批过滤为0 + Where("(orders.status = ? OR user_inventory.order_id = 0 OR user_inventory.order_id IS NULL)", 2). + Group("COALESCE(NULLIF(user_inventory.activity_id, 0), cost_issues.activity_id)"). + Scan(&costStats).Error; err != nil { + h.logger.Error(fmt.Sprintf("GetActivityProfitLoss cost stats error: %v", err)) + } else { + for _, s := range costStats { + if item, ok := activityMap[s.ActivityID]; ok { + item.TotalCost = s.TotalCost + item.PrizeCostBase = s.TotalCostBase + item.PrizeCostFinal = s.TotalCost + item.PrizeCostMultiplier = s.AvgMultiplierX10 + } } } @@ -263,15 +294,14 @@ func (h *handler) DashboardActivityProfitLoss() core.HandlerFunc { } // 6. 计算盈亏和比率 - // 公式: 盈亏 = (支付金额 + 优惠券抵扣 + 次卡价值) - 产品成本 + // 公式: 盈亏 = 用户支出(普通单支付+优惠券 或 次卡价值) - 奖品成本(含道具卡倍率) finalList := make([]activityProfitLossItem, 0, len(activities)) for _, a := range activities { item := activityMap[a.ID] - totalIncome := item.TotalRevenue + item.TotalDiscount + item.TotalGamePassValue - item.Profit = totalIncome - item.TotalCost - if totalIncome > 0 { - item.ProfitRate = float64(item.Profit) / float64(totalIncome) - } + item.SpendingPaidCoupon = item.TotalRevenue + item.TotalDiscount + item.SpendingGamePass = item.TotalGamePassValue + totalIncome := item.SpendingPaidCoupon + item.SpendingGamePass + item.Profit, item.ProfitRate = financesvc.ComputeProfit(totalIncome, item.TotalCost) finalList = append(finalList, *item) } @@ -417,6 +447,7 @@ func (h *handler) DashboardActivityLogs() core.HandlerFunc { DrawCount int64 // 该订单的总抽奖次数(用于金额分摊) UsedDrawLogID int64 // 道具卡实际使用的日志ID CreatedAt time.Time + ActivityPrice int64 } logsQuery := db.Table(model.TableNameActivityDrawLogs). @@ -444,9 +475,11 @@ func (h *handler) DashboardActivityLogs() core.HandlerFunc { COALESCE(orders.order_no, '') as order_no, COALESCE(order_draw_counts.draw_count, 1) as draw_count, COALESCE(user_item_cards.used_draw_log_id, 0) as used_draw_log_id, - activity_draw_logs.created_at + activity_draw_logs.created_at, + COALESCE(activities.price_draw, 0) as activity_price `). Joins("JOIN activity_issues ON activity_issues.id = activity_draw_logs.issue_id"). + Joins("JOIN activities ON activities.id = activity_issues.activity_id"). Joins("LEFT JOIN users ON users.id = activity_draw_logs.user_id"). Joins("LEFT JOIN activity_reward_settings ON activity_reward_settings.id = activity_draw_logs.reward_id"). Joins("LEFT JOIN products ON products.id = activity_reward_settings.product_id"). @@ -589,6 +622,14 @@ func (h *handler) DashboardActivityLogs() core.HandlerFunc { perDrawDiscountAmount := l.DiscountAmount / drawCount perDrawPointsAmount := l.PointsAmount / drawCount + if paymentDetails.GamePassUsed { + if l.ActivityPrice > 0 { + perDrawOrderAmount = l.ActivityPrice + } else if perDrawOrderAmount == 0 { + perDrawOrderAmount = l.OrderAmount / drawCount + } + } + // 设置支付详情中的分摊金额 paymentDetails.CouponDiscount = perDrawDiscountAmount paymentDetails.PointsDiscount = perDrawPointsAmount diff --git a/internal/api/admin/livestream_admin.go b/internal/api/admin/livestream_admin.go index 592c7c9..2b706d3 100755 --- a/internal/api/admin/livestream_admin.go +++ b/internal/api/admin/livestream_admin.go @@ -1,6 +1,9 @@ package admin import ( + "context" + "errors" + "fmt" "net/http" "strconv" "strings" @@ -10,6 +13,7 @@ import ( "bindbox-game/internal/pkg/core" "bindbox-game/internal/pkg/validation" "bindbox-game/internal/repository/mysql/model" + "bindbox-game/internal/service/channel" "bindbox-game/internal/service/livestream" "gorm.io/gorm" @@ -21,6 +25,7 @@ type createLivestreamActivityRequest struct { Name string `json:"name" binding:"required"` StreamerName string `json:"streamer_name"` StreamerContact string `json:"streamer_contact"` + ChannelID *int64 `json:"channel_id"` DouyinProductID string `json:"douyin_product_id"` OrderRewardType string `json:"order_reward_type"` // 下单奖励类型: flip_card/minesweeper OrderRewardQuantity int32 `json:"order_reward_quantity"` // 下单奖励数量: 1-100 @@ -34,6 +39,9 @@ type livestreamActivityResponse struct { Name string `json:"name"` StreamerName string `json:"streamer_name"` StreamerContact string `json:"streamer_contact"` + ChannelID int64 `json:"channel_id"` + ChannelCode string `json:"channel_code"` + ChannelName string `json:"channel_name"` AccessCode string `json:"access_code"` DouyinProductID string `json:"douyin_product_id"` OrderRewardType string `json:"order_reward_type"` // 下单奖励类型 @@ -64,10 +72,35 @@ func (h *handler) CreateLivestreamActivity() core.HandlerFunc { return } + var channelCode string + var channelName string + if req.ChannelID != nil && *req.ChannelID > 0 { + if ch, err := h.channel.GetByID(ctx.RequestContext(), *req.ChannelID); err == nil { + channelCode = ch.Code + channelName = ch.Name + if req.StreamerName == "" { + req.StreamerName = ch.Name + } + } else if err == channel.ErrChannelNotFound { + ctx.AbortWithError(core.Error(http.StatusBadRequest, code.ParamBindError, "渠道不存在")) + return + } else { + ctx.AbortWithError(core.Error(http.StatusInternalServerError, code.ServerError, err.Error())) + return + } + } + input := livestream.CreateActivityInput{ - Name: req.Name, - StreamerName: req.StreamerName, - StreamerContact: req.StreamerContact, + Name: req.Name, + StreamerName: req.StreamerName, + StreamerContact: req.StreamerContact, + ChannelID: func() int64 { + if req.ChannelID != nil { + return *req.ChannelID + } + return 0 + }(), + ChannelCode: channelCode, DouyinProductID: req.DouyinProductID, OrderRewardType: req.OrderRewardType, OrderRewardQuantity: req.OrderRewardQuantity, @@ -91,11 +124,19 @@ func (h *handler) CreateLivestreamActivity() core.HandlerFunc { return } + displayChannelName := channelName + if displayChannelName == "" && activity.ChannelCode != "" { + displayChannelName = activity.ChannelCode + } + ctx.Payload(&livestreamActivityResponse{ ID: activity.ID, Name: activity.Name, StreamerName: activity.StreamerName, StreamerContact: activity.StreamerContact, + ChannelID: activity.ChannelID, + ChannelCode: activity.ChannelCode, + ChannelName: displayChannelName, AccessCode: activity.AccessCode, DouyinProductID: activity.DouyinProductID, OrderRewardType: activity.OrderRewardType, @@ -111,6 +152,7 @@ type updateLivestreamActivityRequest struct { Name string `json:"name"` StreamerName string `json:"streamer_name"` StreamerContact string `json:"streamer_contact"` + ChannelID *int64 `json:"channel_id"` DouyinProductID string `json:"douyin_product_id"` OrderRewardType string `json:"order_reward_type"` // 下单奖励类型 OrderRewardQuantity *int32 `json:"order_reward_quantity"` // 下单奖励数量 @@ -146,6 +188,29 @@ func (h *handler) UpdateLivestreamActivity() core.HandlerFunc { return } + var channelCodeValue string + var channelCodePtr *string + if req.ChannelID != nil { + if *req.ChannelID > 0 { + if ch, err := h.channel.GetByID(ctx.RequestContext(), *req.ChannelID); err == nil { + channelCodeValue = ch.Code + channelCodePtr = &channelCodeValue + if req.StreamerName == "" { + req.StreamerName = ch.Name + } + } else if err == channel.ErrChannelNotFound { + ctx.AbortWithError(core.Error(http.StatusBadRequest, code.ParamBindError, "渠道不存在")) + return + } else { + ctx.AbortWithError(core.Error(http.StatusInternalServerError, code.ServerError, err.Error())) + return + } + } else { + channelCodeValue = "" + channelCodePtr = &channelCodeValue + } + } + input := livestream.UpdateActivityInput{ Name: req.Name, StreamerName: req.StreamerName, @@ -155,6 +220,8 @@ func (h *handler) UpdateLivestreamActivity() core.HandlerFunc { OrderRewardQuantity: req.OrderRewardQuantity, TicketPrice: req.TicketPrice, Status: req.Status, + ChannelID: req.ChannelID, + ChannelCode: channelCodePtr, } if req.StartTime != "" { @@ -224,6 +291,14 @@ func (h *handler) ListLivestreamActivities() core.HandlerFunc { return } + channelIDs := make([]int64, 0, len(list)) + for _, a := range list { + if a.ChannelID > 0 { + channelIDs = append(channelIDs, a.ChannelID) + } + } + channelNameMap := h.loadChannelNames(ctx.RequestContext(), channelIDs) + res := &listLivestreamActivitiesResponse{ List: make([]livestreamActivityResponse, len(list)), Total: total, @@ -245,6 +320,13 @@ func (h *handler) ListLivestreamActivities() core.HandlerFunc { Status: a.Status, CreatedAt: a.CreatedAt.Format("2006-01-02 15:04:05"), } + item.ChannelID = a.ChannelID + item.ChannelCode = a.ChannelCode + if name := channelNameMap[a.ChannelID]; name != "" { + item.ChannelName = name + } else if a.ChannelCode != "" { + item.ChannelName = a.ChannelCode + } if !a.StartTime.IsZero() { item.StartTime = a.StartTime.Format("2006-01-02 15:04:05") } @@ -283,11 +365,24 @@ func (h *handler) GetLivestreamActivity() core.HandlerFunc { return } + channelName := "" + if activity.ChannelID > 0 { + if names := h.loadChannelNames(ctx.RequestContext(), []int64{activity.ChannelID}); len(names) > 0 { + channelName = names[activity.ChannelID] + } + } + if channelName == "" && activity.ChannelCode != "" { + channelName = activity.ChannelCode + } + res := &livestreamActivityResponse{ ID: activity.ID, Name: activity.Name, StreamerName: activity.StreamerName, StreamerContact: activity.StreamerContact, + ChannelID: activity.ChannelID, + ChannelCode: activity.ChannelCode, + ChannelName: channelName, AccessCode: activity.AccessCode, DouyinProductID: activity.DouyinProductID, OrderRewardType: activity.OrderRewardType, @@ -335,6 +430,41 @@ func (h *handler) DeleteLivestreamActivity() core.HandlerFunc { } } +func (h *handler) loadChannelNames(ctx context.Context, ids []int64) map[int64]string { + result := make(map[int64]string) + if len(ids) == 0 { + return result + } + + unique := make([]int64, 0, len(ids)) + seen := make(map[int64]struct{}) + for _, id := range ids { + if id <= 0 { + continue + } + if _, ok := seen[id]; ok { + continue + } + seen[id] = struct{}{} + unique = append(unique, id) + } + if len(unique) == 0 { + return result + } + + channels, err := h.readDB.Channels.WithContext(ctx). + Select(h.readDB.Channels.ID, h.readDB.Channels.Name). + Where(h.readDB.Channels.ID.In(unique...)). + Find() + if err != nil { + return result + } + for _, ch := range channels { + result[ch.ID] = ch.Name + } + return result +} + // ========== 直播间奖品管理 ========== type createLivestreamPrizeRequest struct { @@ -630,6 +760,17 @@ func (h *handler) ListLivestreamDrawLogs() core.HandlerFunc { return } + var activity model.LivestreamActivities + if err := h.repo.GetDbR().Select("id, ticket_price").Where("id = ?", activityID).First(&activity).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + ctx.AbortWithError(core.Error(http.StatusNotFound, code.ParamBindError, "活动不存在")) + } else { + ctx.AbortWithError(core.Error(http.StatusInternalServerError, code.ServerError, err.Error())) + } + return + } + ticketPrice := int64(activity.TicketPrice) + req := new(listLivestreamDrawLogsRequest) _ = ctx.ShouldBindForm(req) @@ -710,10 +851,11 @@ func (h *handler) ListLivestreamDrawLogs() core.HandlerFunc { DouyinOrderID int64 PrizeID int64 ShopOrderID string // 用于关联退款状态查 douyin_orders + LocalUserID int64 } var metas []logMeta // 使用不带分页的 db 克隆 - if err := db.Session(&gorm.Session{}).Select("douyin_order_id, prize_id, shop_order_id").Scan(&metas).Error; err == nil { + if err := db.Session(&gorm.Session{}).Select("douyin_order_id, prize_id, shop_order_id, local_user_id").Scan(&metas).Error; err == nil { orderIDs := make([]int64, 0, len(metas)) distinctOrderIDs := make(map[int64]bool) prizeIDCount := make(map[int64]int64) @@ -730,61 +872,97 @@ func (h *handler) ListLivestreamDrawLogs() core.HandlerFunc { if len(orderIDs) > 0 { var orders []model.DouyinOrders // 分批查询防止 IN 子句过长? 暂时假设量级可控 - h.repo.GetDbR().Select("id, actual_pay_amount, order_status"). + h.repo.GetDbR().Select("id, actual_pay_amount, order_status, pay_type_desc, product_count"). Where("id IN ?", orderIDs).Find(&orders) orderRefundMap := make(map[int64]bool) for _, o := range orders { // 统计营收 (总流水) - stats.TotalRev += int64(o.ActualPayAmount) + orderAmount := calcLivestreamOrderAmount(&o, ticketPrice) + stats.TotalRev += orderAmount if o.OrderStatus == 4 { // 已退款 - stats.TotalRefund += int64(o.ActualPayAmount) + stats.TotalRefund += orderAmount orderRefundMap[o.ID] = true } } - // 4. 统计成本 (剔除退款订单) + // 4. 统计成本 (剔除退款订单,优先资产快照,缺失回退 prize.cost_price) for _, m := range metas { if !orderRefundMap[m.DouyinOrderID] { prizeIDCount[m.PrizeID]++ } } - // 计算奖品成本 (逻辑参考 GetLivestreamStats,简化版) + prizeCostMap := make(map[int64]int64) if len(prizeIDCount) > 0 { prizeIDs := make([]int64, 0, len(prizeIDCount)) for pid := range prizeIDCount { prizeIDs = append(prizeIDs, pid) } - var prizes []model.LivestreamPrizes - h.repo.GetDbR().Where("id IN ?", prizeIDs).Find(&prizes) - - // 批量获取关联商品 - productIDs := make([]int64, 0) + h.repo.GetDbR().Select("id, cost_price").Where("id IN ?", prizeIDs).Find(&prizes) for _, p := range prizes { - if p.CostPrice == 0 && p.ProductID > 0 { - productIDs = append(productIDs, p.ProductID) - } + prizeCostMap[p.ID] = p.CostPrice } - productPriceMap := make(map[int64]int64) - if len(productIDs) > 0 { - var products []model.Products - h.repo.GetDbR().Select("id, price").Where("id IN ?", productIDs).Find(&products) - for _, prod := range products { - productPriceMap[prod.ID] = prod.Price + } + + // 预加载用户资产快照用于 shop_order_id 命中 + type invRow struct { + UserID int64 + ValueCents int64 + Remark string + } + var invRows []invRow + _ = 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"). + 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("status IN (1,3)"). + Where("COALESCE(user_inventory.remark, '') NOT LIKE ?", "%void%"). + Where("user_id > 0"). + Scan(&invRows).Error + invByUser := make(map[int64][]invRow) + for _, v := range invRows { + invByUser[v.UserID] = append(invByUser[v.UserID], v) + } + metasByKey := make(map[string][]logMeta) + keyUser := make(map[string]int64) + keyOrder := make(map[string]string) + for _, m := range metas { + if orderRefundMap[m.DouyinOrderID] { + continue + } + key := fmt.Sprintf("%d|%s", m.LocalUserID, m.ShopOrderID) + metasByKey[key] = append(metasByKey[key], m) + keyUser[key] = m.LocalUserID + keyOrder[key] = m.ShopOrderID + } + + for key, rows := range metasByKey { + if len(rows) == 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 + } } } - for _, p := range prizes { - cost := p.CostPrice - if cost == 0 && p.ProductID > 0 { - cost = productPriceMap[p.ProductID] - } - count := prizeIDCount[p.ID] - stats.TotalCost += cost * count + if snapshotSum > 0 { + stats.TotalCost += snapshotSum + continue + } + + for _, r := range rows { + stats.TotalCost += prizeCostMap[r.PrizeID] } } } diff --git a/internal/api/admin/livestream_helpers.go b/internal/api/admin/livestream_helpers.go new file mode 100644 index 0000000..bf82442 --- /dev/null +++ b/internal/api/admin/livestream_helpers.go @@ -0,0 +1,36 @@ +package admin + +import ( + "strings" + + "bindbox-game/internal/repository/mysql/model" +) + +// calcLivestreamOrderAmount returns the effective revenue contribution for a Douyin order. +// For regular paid orders it returns actual_pay_amount; for 次卡订单 (actual pay is 0 but +// pay_type_desc contains 次卡), it falls back to the activity ticket price. +func calcLivestreamOrderAmount(order *model.DouyinOrders, ticketPrice int64) int64 { + if order == nil { + return 0 + } + + amount := int64(order.ActualPayAmount) + if amount > 0 || ticketPrice <= 0 { + return amount + } + + desc := strings.ReplaceAll(strings.TrimSpace(order.PayTypeDesc), " ", "") + if desc == "" { + return amount + } + + if strings.Contains(desc, "次卡") { + multiplier := int64(order.ProductCount) + if multiplier <= 0 { + multiplier = 1 + } + return ticketPrice * multiplier + } + + return amount +} diff --git a/internal/api/admin/livestream_stats.go b/internal/api/admin/livestream_stats.go index cb9132f..a6abf74 100755 --- a/internal/api/admin/livestream_stats.go +++ b/internal/api/admin/livestream_stats.go @@ -4,6 +4,7 @@ import ( "math" "net/http" "strconv" + "strings" "bindbox-game/internal/code" "bindbox-game/internal/pkg/core" @@ -77,54 +78,98 @@ func (h *handler) GetLivestreamStats() core.HandlerFunc { ctx.AbortWithError(core.Error(http.StatusNotFound, code.ServerError, "活动不存在")) return } - // ticketPrice 暂未使用,但在统计中可能作为参考,这里移除未使用的报错 + 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") - // 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") + "'" + orderQuery = orderQuery.Where("created_at >= ?", startTime) } if endTime != nil { - queryRevenue += " AND l.created_at <= '" + endTime.Format("2006-01-02 15:04:05") + "'" + orderQuery = orderQuery.Where("created_at <= ?", endTime) } - 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") + "'" + 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 } - 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) + 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 @@ -138,141 +183,130 @@ func (h *handler) GetLivestreamStats() core.HandlerFunc { 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. 计算成本(优先资产快照 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{}{} + } } - // 4. 计算成本(使用 draw_logs.product_id 直接关联 products 表) - // 收集未退款订单的 product_id 和对应数量 - productIDCountMap := make(map[int64]int64) + 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 } - // 使用 draw_logs 中记录的 product_id - if log.ProductID > 0 { - productIDCountMap[log.ProductID]++ - } + 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 - productCostMap := make(map[int64]int64) - if len(productIDCountMap) > 0 { - productIDs := make([]int64, 0, len(productIDCountMap)) - for pid := range productIDCountMap { - productIDs = append(productIDs, pid) + for key, refs := range logsByKey { + if len(refs) == 0 { + continue } + uid := keyUser[key] + shopOrderID := keyOrder[key] - 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 + var snapshotSum int64 + if uid > 0 && shopOrderID != "" { + for _, inv := range invByUser[uid] { + if strings.Contains(inv.Remark, shopOrderID) { + snapshotSum += inv.ValueCents + } } } - } - // 构建 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] { + 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 } - if log.ProductID <= 0 { - continue + + for _, r := range refs { + c := prizeCostMap[r.PrizeID] + totalCost += c + costByDate[r.DateKey] += c } - dateKey := log.CreatedAt.Format("2006-01-02") + } + + // 5. 按天分组统计 (营收/退款已在 dailyMap 中累计),补充成本 + for dateKey, c := range costByDate { ds := dailyMap[dateKey] - if ds != nil { - if cost, ok := prizeCostMap[log.ProductID]; ok { - ds.TotalCost += cost - } + if ds == nil { + ds = &dailyLivestreamStats{Date: dateKey} + dailyMap[dateKey] = ds } + ds.TotalCost += c } // 6. 汇总每日数据并计算总体指标