package admin import ( "bindbox-game/internal/code" "bindbox-game/internal/pkg/core" "bindbox-game/internal/pkg/validation" "bindbox-game/internal/repository/mysql/model" financesvc "bindbox-game/internal/service/finance" "fmt" "net/http" "sort" "strings" "time" ) type spendingLeaderboardRequest struct { Page int `form:"page"` PageSize int `form:"page_size"` RangeType string `form:"rangeType"` // today, 7d, 30d, custom StartDate string `form:"start"` EndDate string `form:"end"` SortBy string `form:"sort_by"` // spending, profit } type spendingLeaderboardItem struct { UserID int64 `json:"user_id"` Nickname string `json:"nickname"` Avatar string `json:"avatar"` OrderCount int64 `json:"-"` // Hidden TotalSpending int64 `json:"-"` // Hidden TotalPrizeValue int64 `json:"-"` // Hidden 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"` TotalDiscount int64 `json:"total_discount"` // Total Coupon Discount (Fen) TotalPoints int64 `json:"total_points"` // Total Points Discount (Fen) GamePassCount int64 `json:"game_pass_count"` // Count of SourceType=4 ItemCardCount int64 `json:"item_card_count"` // Count where ItemCardID > 0 // Breakdown by game type IchibanSpending int64 `json:"ichiban_spending"` IchibanPrize int64 `json:"ichiban_prize"` IchibanProfit int64 `json:"ichiban_profit"` IchibanCount int64 `json:"ichiban_count"` InfiniteSpending int64 `json:"infinite_spending"` InfinitePrize int64 `json:"infinite_prize"` InfiniteProfit int64 `json:"infinite_profit"` InfiniteCount int64 `json:"infinite_count"` MatchingSpending int64 `json:"matching_spending"` MatchingPrize int64 `json:"matching_prize"` MatchingProfit int64 `json:"matching_profit"` MatchingCount int64 `json:"matching_count"` // 直播间统计 (source_type=5) LivestreamSpending int64 `json:"livestream_spending"` LivestreamPrize int64 `json:"livestream_prize"` LivestreamProfit int64 `json:"livestream_profit"` LivestreamCount int64 `json:"livestream_count"` Profit int64 `json:"profit"` // Spending - PrizeValue ProfitRate float64 `json:"profit_rate"` // Profit / Spending } type spendingLeaderboardResponse struct { Page int `json:"page"` PageSize int `json:"page_size"` Total int64 `json:"total"` List []spendingLeaderboardItem `json:"list"` } func (h *handler) DashboardPlayerSpendingLeaderboard() core.HandlerFunc { return func(ctx core.Context) { req := new(spendingLeaderboardRequest) if err := ctx.ShouldBindForm(req); err != nil { ctx.AbortWithError(core.Error(http.StatusBadRequest, code.ParamBindError, validation.Error(err))) return } if req.Page <= 0 { req.Page = 1 } if req.PageSize <= 0 { req.PageSize = 20 } var start, end time.Time if req.RangeType != "all" { start, end = parseRange(req.RangeType, req.StartDate, req.EndDate) h.logger.Info(fmt.Sprintf("SpendingLeaderboard range: start=%v, end=%v, type=%s", start, end, req.RangeType)) } else { h.logger.Info("SpendingLeaderboard range: ALL TIME") } db := h.repo.GetDbR().WithContext(ctx.RequestContext()) // 1. Get Top Spenders from Orders type orderStat struct { UserID int64 TotalAmount int64 // ActualAmount OrderCount int64 TotalDiscount int64 TotalPoints int64 GamePassCount int64 GamePassSpending int64 ItemCardCount int64 IchibanSpending int64 IchibanCount int64 InfiniteSpending int64 InfiniteCount int64 MatchingSpending int64 MatchingCount int64 LivestreamSpending int64 LivestreamCount int64 } var stats []orderStat query := db.Table(model.TableNameOrders). Joins("LEFT JOIN (SELECT l.order_id, MAX(a.activity_category_id) as category_id, MAX(a.price_draw) as price_draw, COUNT(*) as draw_count FROM activity_draw_logs l JOIN activity_issues i ON i.id = l.issue_id JOIN activities a ON a.id = i.activity_id GROUP BY l.order_id) oa ON oa.order_id = orders.id"). Where("orders.status = ?", 2) if req.RangeType != "all" { query = query.Where("orders.created_at >= ?", start).Where("orders.created_at <= ?", end) } if err := query.Select(` orders.user_id, 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 COALESCE(oa.price_draw * oa.draw_count, 0) ELSE orders.actual_amount + orders.discount_amount END) as total_amount, COUNT(orders.id) as order_count, SUM(orders.discount_amount) as total_discount, SUM(orders.points_amount) as total_points, SUM(CASE WHEN orders.source_type = 4 THEN 1 ELSE 0 END) as game_pass_count, 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 COALESCE(oa.price_draw * oa.draw_count, 0) ELSE 0 END) as game_pass_spending, SUM(CASE WHEN orders.item_card_id > 0 THEN 1 ELSE 0 END) as item_card_count, SUM(CASE WHEN oa.category_id = 1 THEN 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 COALESCE(oa.price_draw * oa.draw_count, 0) ELSE orders.actual_amount + orders.discount_amount END ELSE 0 END) as ichiban_spending, SUM(CASE WHEN oa.category_id = 1 THEN 1 ELSE 0 END) as ichiban_count, SUM(CASE WHEN oa.category_id = 2 THEN 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 COALESCE(oa.price_draw * oa.draw_count, 0) ELSE orders.actual_amount + orders.discount_amount END ELSE 0 END) as infinite_spending, SUM(CASE WHEN oa.category_id = 2 THEN 1 ELSE 0 END) as infinite_count, SUM(CASE WHEN oa.category_id = 3 THEN 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 COALESCE(oa.price_draw * oa.draw_count, 0) ELSE orders.actual_amount + orders.discount_amount END ELSE 0 END) as matching_spending, SUM(CASE WHEN oa.category_id = 3 THEN 1 ELSE 0 END) as matching_count, 0 as livestream_spending, 0 as livestream_count `). Group("orders.user_id"). Order("total_amount DESC"). Limit(100). Scan(&stats).Error; err != nil { h.logger.Error(fmt.Sprintf("SpendingLeaderboard SQL error: %v", err)) ctx.AbortWithError(core.Error(http.StatusBadRequest, 21020, err.Error())) return } h.logger.Info(fmt.Sprintf("SpendingLeaderboard SQL done: count=%d", len(stats))) // 2. Collect User IDs userIDs := make([]int64, 0, len(stats)) statMap := make(map[int64]*spendingLeaderboardItem) for _, s := range stats { userIDs = append(userIDs, s.UserID) statMap[s.UserID] = &spendingLeaderboardItem{ UserID: s.UserID, TotalSpending: s.TotalAmount, OrderCount: s.OrderCount, TotalDiscount: s.TotalDiscount, TotalPoints: s.TotalPoints, GamePassCount: s.GamePassCount, SpendingGamePass: s.GamePassSpending, ItemCardCount: s.ItemCardCount, IchibanSpending: s.IchibanSpending, IchibanCount: s.IchibanCount, InfiniteSpending: s.InfiniteSpending, InfiniteCount: s.InfiniteCount, MatchingSpending: s.MatchingSpending, MatchingCount: s.MatchingCount, LivestreamSpending: 0, // Will be updated from douyin_orders LivestreamCount: s.LivestreamCount, } } // 2.1 Fetch Real Douyin Spending if len(userIDs) > 0 { type dyStat struct { UserID int64 Amount int64 Count int64 } var dyStats []dyStat dyQuery := h.repo.GetDbR().Table("douyin_orders"). Select("CAST(local_user_id AS SIGNED) as user_id, SUM(actual_pay_amount) as amount, COUNT(*) as count"). Where("local_user_id IN ?", userIDs). Where("local_user_id != '' AND local_user_id != '0'") if req.RangeType != "all" { dyQuery = dyQuery.Where("created_at >= ?", start).Where("created_at <= ?", end) } if err := dyQuery.Group("local_user_id").Scan(&dyStats).Error; err == nil { for _, ds := range dyStats { if item, ok := statMap[ds.UserID]; ok { item.LivestreamSpending = ds.Amount item.LivestreamCount = ds.Count // Use real paid order count item.TotalSpending += ds.Amount } } } } if len(userIDs) > 0 { // 3. Get User Info // Use h.readDB.Users (GEN) as it's simple users, _ := h.readDB.Users.WithContext(ctx.RequestContext()).ReadDB().Where(h.readDB.Users.ID.In(userIDs...)).Find() for _, u := range users { if item, ok := statMap[u.ID]; ok { item.Nickname = u.Nickname item.Avatar = u.Avatar } } // 4. Calculate Prize Value (Inventory) type invStat struct { UserID int64 TotalValue int64 IchibanPrize int64 InfinitePrize int64 MatchingPrize int64 LivestreamPrize int64 } var invStats []invStat // Join with Products, Activities, and Orders (for livestream detection) query := db.Table(model.TableNameUserInventory). 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 activities ON activities.id = COALESCE(NULLIF(user_inventory.activity_id, 0), cost_issues.activity_id)"). Joins("LEFT JOIN orders ON orders.id = user_inventory.order_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("user_inventory.user_id IN ?", userIDs) if req.RangeType != "all" { query = query.Where("user_inventory.created_at >= ?", start). Where("user_inventory.created_at <= ?", end) } // Only include Holding (1) and Shipped/Used (3) items. Exclude Void/Decomposed (2). query = query.Where("user_inventory.status IN ?", []int{1, 3}). Where("COALESCE(user_inventory.remark, '') NOT LIKE ?", "%void%") err := query.Select(` user_inventory.user_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_value, CAST(SUM(CASE WHEN activities.activity_category_id = 1 THEN 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 ELSE 0 END) AS SIGNED) as ichiban_prize, CAST(SUM(CASE WHEN activities.activity_category_id = 2 THEN 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 ELSE 0 END) AS SIGNED) as infinite_prize, CAST(SUM(CASE WHEN activities.activity_category_id = 3 THEN 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 ELSE 0 END) AS SIGNED) as matching_prize `). Group("user_inventory.user_id"). Scan(&invStats).Error if err == nil { for _, is := range invStats { if item, ok := statMap[is.UserID]; ok { item.TotalPrizeValue = is.TotalValue item.IchibanPrize = is.IchibanPrize item.InfinitePrize = is.InfinitePrize item.MatchingPrize = is.MatchingPrize } } } else { h.logger.Error(fmt.Sprintf("DashboardPlayerSpendingLeaderboard inventory cost stats error: %v", err)) } // 4.1 Calculate Livestream Prize Value (snapshot priority; fallback prize cost) type lsLog struct { UserID int64 ShopOrderID string PrizeID int64 } var lsLogs []lsLog lsLogQuery := db.Table(model.TableNameLivestreamDrawLogs). Select("livestream_draw_logs.local_user_id as user_id, livestream_draw_logs.shop_order_id, livestream_draw_logs.prize_id"). Where("livestream_draw_logs.local_user_id IN ?", userIDs). Where("livestream_draw_logs.is_refunded = 0"). Where("livestream_draw_logs.prize_id > 0") if req.RangeType != "all" { lsLogQuery = lsLogQuery.Where("livestream_draw_logs.created_at >= ?", start). Where("livestream_draw_logs.created_at <= ?", end) } _ = lsLogQuery.Scan(&lsLogs).Error if len(lsLogs) > 0 { prizeIDSet := make(map[int64]struct{}) for _, l := range lsLogs { prizeIDSet[l.PrizeID] = struct{}{} } prizeIDs := make([]int64, 0, len(prizeIDSet)) for pid := range prizeIDSet { prizeIDs = append(prizeIDs, pid) } prizeCostMap := make(map[int64]int64) if len(prizeIDs) > 0 { var prizes []struct { ID int64 CostPrice int64 } _ = h.repo.GetDbR().Table("livestream_prizes").Select("id, cost_price").Where("id IN ?", prizeIDs).Scan(&prizes).Error for _, p := range prizes { prizeCostMap[p.ID] = p.CostPrice } } type invRow struct { UserID int64 ValueCents int64 Remark string } var invRows []invRow invQ := 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("user_inventory.user_id IN ?", userIDs). Where("user_inventory.status IN (1,3)"). Where("COALESCE(user_inventory.remark, '') NOT LIKE ?", "%void%") if req.RangeType != "all" { invQ = invQ.Where("user_inventory.created_at >= ?", start.Add(-2*time.Hour)). Where("user_inventory.created_at <= ?", end.Add(24*time.Hour)) } _ = invQ.Scan(&invRows).Error invByUser := make(map[int64][]invRow) for _, inv := range invRows { invByUser[inv.UserID] = append(invByUser[inv.UserID], inv) } lsByKey := make(map[string][]lsLog) for _, l := range lsLogs { key := fmt.Sprintf("%d|%s", l.UserID, l.ShopOrderID) lsByKey[key] = append(lsByKey[key], l) } livestreamPrizeByUser := make(map[int64]int64) for _, logs := range lsByKey { if len(logs) == 0 { continue } uid := logs[0].UserID shopOrderID := logs[0].ShopOrderID var snapshotSum int64 if shopOrderID != "" { for _, inv := range invByUser[uid] { if strings.Contains(inv.Remark, shopOrderID) { snapshotSum += inv.ValueCents } } } if snapshotSum > 0 { livestreamPrizeByUser[uid] += snapshotSum continue } for _, l := range logs { livestreamPrizeByUser[uid] += prizeCostMap[l.PrizeID] } } for uid, amount := range livestreamPrizeByUser { if item, ok := statMap[uid]; ok { item.LivestreamPrize = amount } } } // 4.2 Calculate Profit for each category for _, item := range statMap { item.IchibanProfit = item.IchibanSpending - item.IchibanPrize item.InfiniteProfit = item.InfiniteSpending - item.InfinitePrize item.MatchingProfit = item.MatchingSpending - item.MatchingPrize item.LivestreamProfit = item.LivestreamSpending - item.LivestreamPrize } } // 5. Calculate Profit and Final List list := make([]spendingLeaderboardItem, 0, len(statMap)) for _, item := range statMap { // Calculate totals based on the 4 displayed categories to ensure UI consistency calculatedSpending := item.IchibanSpending + item.InfiniteSpending + item.MatchingSpending + item.LivestreamSpending calculatedProfit := item.IchibanProfit + item.InfiniteProfit + item.MatchingProfit + item.LivestreamProfit item.Profit = calculatedProfit _, item.ProfitRate = financesvc.ComputeProfit(calculatedSpending, calculatedSpending-item.Profit) item.SpendingPaidCoupon = calculatedSpending - item.SpendingGamePass if item.SpendingPaidCoupon < 0 { item.SpendingPaidCoupon = 0 } item.PrizeCostFinal = item.IchibanPrize + item.InfinitePrize + item.MatchingPrize + item.LivestreamPrize item.PrizeCostBase = item.PrizeCostFinal item.PrizeCostMultiplier = 10 list = append(list, *item) } // 6. Sort (in memory since we only have top N spenders) sortBy := req.SortBy if sortBy == "" { sortBy = "spending" } sort.Slice(list, func(i, j int) bool { switch sortBy { case "profit": return list[i].Profit > list[j].Profit // Higher profit first case "profit_asc": return list[i].Profit < list[j].Profit // Lower profit (loss) first default: return list[i].TotalSpending > list[j].TotalSpending } }) // Pagination on the result list startIdx := (req.Page - 1) * req.PageSize if startIdx >= len(list) { startIdx = len(list) } endIdx := startIdx + req.PageSize if endIdx > len(list) { endIdx = len(list) } finalList := list[startIdx:endIdx] if finalList == nil { finalList = []spendingLeaderboardItem{} } ctx.Payload(&spendingLeaderboardResponse{ Page: req.Page, PageSize: req.PageSize, Total: int64(len(list)), // Total of the fetched top batch List: finalList, }) } }