344 lines
12 KiB
Go
Executable File
344 lines
12 KiB
Go
Executable File
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("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(user_inventory.value_cents) as total_value,
|
|
SUM(CASE WHEN activities.activity_category_id = 1 THEN user_inventory.value_cents ELSE 0 END) as ichiban_prize,
|
|
SUM(CASE WHEN activities.activity_category_id = 2 THEN user_inventory.value_cents ELSE 0 END) as infinite_prize,
|
|
SUM(CASE WHEN activities.activity_category_id = 3 THEN user_inventory.value_cents 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,
|
|
})
|
|
}
|
|
}
|