package admin import ( "bindbox-game/internal/code" "bindbox-game/internal/pkg/core" "bindbox-game/internal/pkg/validation" "bindbox-game/internal/repository/mysql/model" "fmt" "net/http" "sort" "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 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 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 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(orders.total_amount) 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.item_card_id > 0 THEN 1 ELSE 0 END) as item_card_count, SUM(CASE WHEN oa.category_id = 1 THEN orders.total_amount 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 orders.total_amount 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 orders.total_amount ELSE 0 END) as matching_spending, SUM(CASE WHEN oa.category_id = 3 THEN 1 ELSE 0 END) as matching_count, SUM(CASE WHEN orders.source_type = 5 THEN orders.total_amount ELSE 0 END) as livestream_spending, SUM(CASE WHEN orders.source_type = 5 THEN 1 ELSE 0 END) 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, 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 // Add to total since orders.total_amount was 0 for these } } } } 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("JOIN products ON products.id = user_inventory.product_id"). Joins("LEFT JOIN activities ON activities.id = user_inventory.activity_id"). Joins("LEFT JOIN orders ON orders.id = user_inventory.order_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("user_inventory.remark NOT LIKE ?", "%void%") err := query.Select(` user_inventory.user_id, SUM(products.price) as total_value, SUM(CASE WHEN activities.activity_category_id = 1 THEN products.price ELSE 0 END) as ichiban_prize, SUM(CASE WHEN activities.activity_category_id = 2 THEN products.price ELSE 0 END) as infinite_prize, SUM(CASE WHEN activities.activity_category_id = 3 THEN products.price ELSE 0 END) 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 } } } // 4.1 Calculate Livestream Prize Value (From Draw Logs) type lsStat struct { UserID int64 Amount int64 } var lsStats []lsStat lsQuery := db.Table(model.TableNameLivestreamDrawLogs). Joins("JOIN products ON products.id = livestream_draw_logs.product_id"). Select("livestream_draw_logs.local_user_id as user_id, SUM(products.price) as amount"). Where("livestream_draw_logs.local_user_id IN ?", userIDs). Where("livestream_draw_logs.is_refunded = 0"). Where("livestream_draw_logs.product_id > 0") if req.RangeType != "all" { lsQuery = lsQuery.Where("livestream_draw_logs.created_at >= ?", start). Where("livestream_draw_logs.created_at <= ?", end) } if err := lsQuery.Group("livestream_draw_logs.local_user_id").Scan(&lsStats).Error; err == nil { for _, ls := range lsStats { if item, ok := statMap[ls.UserID]; ok { item.LivestreamPrize = ls.Amount // item.TotalPrizeValue += ls.Amount // Already included in user_inventory } } } // 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 if calculatedSpending > 0 { item.ProfitRate = float64(item.Profit) / float64(calculatedSpending) } else { item.ProfitRate = 0 } 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, }) } }