bindbox-game/internal/api/admin/dashboard_spending.go
2026-02-03 17:44:02 +08:00

345 lines
12 KiB
Go

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,
})
}
}