459 lines
18 KiB
Go
Executable File
459 lines
18 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"
|
|
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,
|
|
})
|
|
}
|
|
}
|