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:"order_count"` TotalSpending int64 `json:"total_spending"` // Total Paid Amount (Fen) TotalPrizeValue int64 `json:"total_prize_value"` // Total Product Price (Fen) 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"` IchibanCount int64 `json:"ichiban_count"` InfiniteSpending int64 `json:"infinite_spending"` InfinitePrize int64 `json:"infinite_prize"` InfiniteCount int64 `json:"infinite_count"` MatchingSpending int64 `json:"matching_spending"` MatchingPrize int64 `json:"matching_prize"` MatchingCount int64 `json:"matching_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 } var stats []orderStat query := db.Table(model.TableNameOrders). Joins("LEFT JOIN (SELECT l.order_id, MAX(a.play_type) as play_type 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.actual_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.play_type = 'ichiban' THEN orders.actual_amount ELSE 0 END) as ichiban_spending, SUM(CASE WHEN oa.play_type = 'ichiban' THEN 1 ELSE 0 END) as ichiban_count, SUM(CASE WHEN oa.play_type IN ('infinite', 'box') THEN orders.actual_amount ELSE 0 END) as infinite_spending, SUM(CASE WHEN oa.play_type IN ('infinite', 'box') THEN 1 ELSE 0 END) as infinite_count, SUM(CASE WHEN oa.play_type = 'matching' THEN orders.actual_amount ELSE 0 END) as matching_spending, SUM(CASE WHEN oa.play_type = 'matching' THEN 1 ELSE 0 END) as matching_count `). Group("orders.user_id"). Order("total_amount DESC"). Limit(50). 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, len(stats)) statMap := make(map[int64]*spendingLeaderboardItem) for i, s := range stats { userIDs[i] = 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, } } 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 } var invStats []invStat // Join with Products and Activities 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"). 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) } err := query.Select(` user_inventory.user_id, SUM(products.price) as total_value, SUM(CASE WHEN activities.play_type = 'ichiban' THEN products.price ELSE 0 END) as ichiban_prize, SUM(CASE WHEN activities.play_type IN ('infinite', 'box') THEN products.price ELSE 0 END) as infinite_prize, SUM(CASE WHEN activities.play_type = 'matching' 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 } } } } // 5. Calculate Profit and Final List list := make([]spendingLeaderboardItem, 0, len(statMap)) for _, item := range statMap { item.Profit = item.TotalSpending - item.TotalPrizeValue if item.TotalSpending > 0 { item.ProfitRate = float64(item.Profit) / float64(item.TotalSpending) } 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, }) } }