bindbox-game/internal/api/admin/users_profile.go
2026-02-27 00:08:02 +08:00

266 lines
9.8 KiB
Go
Executable File
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package admin
import (
"net/http"
"strconv"
"time"
"bindbox-game/internal/code"
"bindbox-game/internal/pkg/core"
)
// UserProfileResponse 用户综合画像
type UserProfileResponse struct {
// 基本信息
ID int64 `json:"id"`
Nickname string `json:"nickname"`
Avatar string `json:"avatar"`
Mobile string `json:"mobile"`
InviteCode string `json:"invite_code"`
InviterID int64 `json:"inviter_id"`
ChannelID int64 `json:"channel_id"`
CreatedAt string `json:"created_at"`
DouyinID string `json:"douyin_id"`
DouyinUserID string `json:"douyin_user_id"` // 用户的抖音账号ID
InviterNickname string `json:"inviter_nickname"` // 邀请人昵称
// 邀请统计
InviteCount int64 `json:"invite_count"`
// 生命周期财务指标
LifetimeStats struct {
TotalPaid int64 `json:"total_paid"` // 累计支付
TotalRefunded int64 `json:"total_refunded"` // 累计退款
NetCashCost int64 `json:"net_cash_cost"` // 净现金支出
OrderCount int64 `json:"order_count"` // 订单数
TodayPaid int64 `json:"today_paid"` // 当日支付
SevenDayPaid int64 `json:"seven_day_paid"` // 近7天支付
ThirtyDayPaid int64 `json:"thirty_day_paid"` // 近30天支付
} `json:"lifetime_stats"`
// 当前资产快照
CurrentAssets struct {
PointsBalance int64 `json:"points_balance"` // 积分余额
InventoryCount int64 `json:"inventory_count"` // 持有商品数
InventoryValue int64 `json:"inventory_value"` // 持有商品价值
CouponCount int64 `json:"coupon_count"` // 持有优惠券数
CouponValue int64 `json:"coupon_value"` // 持有优惠券价值
ItemCardCount int64 `json:"item_card_count"` // 持有道具卡数
ItemCardValue int64 `json:"item_card_value"` // 持有道具卡价值
GamePassCount int64 `json:"game_pass_count"` // 持有次数卡数
GameTicketCount int64 `json:"game_ticket_count"` // 持有游戏资格数
TotalAssetValue int64 `json:"total_asset_value"` // 总资产估值
ProfitLossRatio float64 `json:"profit_loss_ratio"` // 累计盈亏比
} `json:"current_assets"`
}
// GetUserProfile 获取用户综合画像
// @Summary 获取用户综合画像
// @Description 聚合用户基本信息、生命周期财务指标、当前资产快照
// @Tags 管理端.用户
// @Accept json
// @Produce json
// @Param user_id path integer true "用户ID"
// @Success 200 {object} UserProfileResponse
// @Failure 400 {object} code.Failure
// @Router /api/admin/users/{user_id}/profile [get]
// @Security LoginVerifyToken
func (h *handler) GetUserProfile() core.HandlerFunc {
return func(ctx core.Context) {
userID, err := strconv.ParseInt(ctx.Param("user_id"), 10, 64)
if err != nil {
ctx.AbortWithError(core.Error(http.StatusBadRequest, code.ParamBindError, "未传递用户ID"))
return
}
rsp := new(UserProfileResponse)
// 1. 基本信息
user, err := h.readDB.Users.WithContext(ctx.RequestContext()).Where(h.readDB.Users.ID.Eq(userID)).First()
if err != nil {
ctx.AbortWithError(core.Error(http.StatusNotFound, 20201, "用户不存在"))
return
}
rsp.ID = user.ID
rsp.Nickname = user.Nickname
rsp.Avatar = user.Avatar
rsp.Mobile = user.Mobile
rsp.InviteCode = user.InviteCode
rsp.InviterID = user.InviterID
rsp.ChannelID = user.ChannelID
rsp.DouyinID = user.DouyinID
rsp.DouyinUserID = user.DouyinUserID
rsp.CreatedAt = user.CreatedAt.Format(time.RFC3339)
// 1.1 查询邀请人昵称
if user.InviterID > 0 {
inviter, _ := h.readDB.Users.WithContext(ctx.RequestContext()).Where(h.readDB.Users.ID.Eq(user.InviterID)).First()
if inviter != nil {
rsp.InviterNickname = inviter.Nickname
}
}
// 2. 邀请统计
rsp.InviteCount, _ = h.readDB.Users.WithContext(ctx.RequestContext()).Where(h.readDB.Users.InviterID.Eq(userID)).Count()
// 3. 生命周期财务指标
// 3.1 消费统计
type orderStats struct {
TotalPaid *int64
OrderCount int64
TodayPaid *int64
SevenDayPaid *int64
ThirtyDayPaid *int64
}
var os orderStats
now := time.Now()
todayStart := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location())
sevenDayStart := todayStart.AddDate(0, 0, -6)
thirtyDayStart := todayStart.AddDate(0, 0, -29)
h.readDB.Orders.WithContext(ctx.RequestContext()).ReadDB().
Select(
h.readDB.Orders.ActualAmount.Sum().As("total_paid"),
h.readDB.Orders.ID.Count().As("order_count"),
).
Where(h.readDB.Orders.UserID.Eq(userID)).
Where(h.readDB.Orders.Status.Eq(2)).
Where(h.readDB.Orders.SourceType.In(2, 3, 4)). // 统计抽奖、对对碰、次卡购买(排除积分兑换)
Scan(&os)
// 分阶段统计
h.readDB.Orders.WithContext(ctx.RequestContext()).ReadDB().
Select(h.readDB.Orders.ActualAmount.Sum().As("today_paid")).
Where(h.readDB.Orders.UserID.Eq(userID)).
Where(h.readDB.Orders.Status.Eq(2)).
Where(h.readDB.Orders.SourceType.In(2, 3, 4)). // 统计抽奖、对对碰、次卡购买(排除积分兑换)
Where(h.readDB.Orders.CreatedAt.Gte(todayStart)).
Scan(&os.TodayPaid)
h.readDB.Orders.WithContext(ctx.RequestContext()).ReadDB().
Select(h.readDB.Orders.ActualAmount.Sum().As("seven_day_paid")).
Where(h.readDB.Orders.UserID.Eq(userID)).
Where(h.readDB.Orders.Status.Eq(2)).
Where(h.readDB.Orders.SourceType.In(2, 3, 4)). // 统计抽奖、对对碰、次卡购买(排除积分兑换)
Where(h.readDB.Orders.CreatedAt.Gte(sevenDayStart)).
Scan(&os.SevenDayPaid)
h.readDB.Orders.WithContext(ctx.RequestContext()).ReadDB().
Select(h.readDB.Orders.ActualAmount.Sum().As("thirty_day_paid")).
Where(h.readDB.Orders.UserID.Eq(userID)).
Where(h.readDB.Orders.Status.Eq(2)).
Where(h.readDB.Orders.SourceType.In(2, 3, 4)). // 统计抽奖、对对碰、次卡购买(排除积分兑换)
Where(h.readDB.Orders.CreatedAt.Gte(thirtyDayStart)).
Scan(&os.ThirtyDayPaid)
if os.TotalPaid != nil {
rsp.LifetimeStats.TotalPaid = *os.TotalPaid
}
rsp.LifetimeStats.OrderCount = os.OrderCount
if os.TodayPaid != nil {
rsp.LifetimeStats.TodayPaid = *os.TodayPaid
}
if os.SevenDayPaid != nil {
rsp.LifetimeStats.SevenDayPaid = *os.SevenDayPaid
}
if os.ThirtyDayPaid != nil {
rsp.LifetimeStats.ThirtyDayPaid = *os.ThirtyDayPaid
}
// 3.2 累计退款
var totalRefunded int64
_ = h.repo.GetDbR().Raw(`
SELECT COALESCE(SUM(pr.amount_refund), 0)
FROM payment_refunds pr
JOIN orders o ON o.order_no = pr.order_no COLLATE utf8mb4_unicode_ci
WHERE o.user_id = ? AND pr.status = 'SUCCESS'
`, userID).Scan(&totalRefunded).Error
rsp.LifetimeStats.TotalRefunded = totalRefunded
// 净现金投入 = 累计实付 - 累计退款
rsp.LifetimeStats.NetCashCost = rsp.LifetimeStats.TotalPaid - totalRefunded
if rsp.LifetimeStats.NetCashCost < 0 {
rsp.LifetimeStats.NetCashCost = 0
}
// 4. 当前资产快照
// 4.1 积分余额
var pointsBalance int64
_ = h.repo.GetDbR().Raw("SELECT COALESCE(SUM(points), 0) FROM user_points WHERE user_id = ?", userID).Scan(&pointsBalance).Error
rsp.CurrentAssets.PointsBalance = pointsBalance
// 4.2 持有商品
type invStats struct {
Count int64
Value int64
}
var is invStats
_ = h.repo.GetDbR().Raw(`
SELECT
COUNT(ui.id) as count,
COALESCE(SUM(COALESCE(NULLIF(ui.value_cents, 0), p.price, 0)), 0) as value
FROM user_inventory ui
LEFT JOIN products p ON p.id = ui.product_id
WHERE ui.user_id = ? AND ui.status = 1
`, userID).Scan(&is).Error
rsp.CurrentAssets.InventoryCount = is.Count
rsp.CurrentAssets.InventoryValue = is.Value
// 4.3 持有优惠券
type cpStats struct {
Count int64
Value int64
}
var cs cpStats
_ = h.repo.GetDbR().Raw(`
SELECT COUNT(*) AS count, COALESCE(SUM(balance_amount), 0) AS value
FROM user_coupons
WHERE user_id = ? AND status = 1
`, userID).Scan(&cs).Error
rsp.CurrentAssets.CouponCount = cs.Count
rsp.CurrentAssets.CouponValue = cs.Value
// 4.4 持有道具卡
type cardStats struct {
Count int64
Value int64
}
var cds cardStats
h.readDB.UserItemCards.WithContext(ctx.RequestContext()).ReadDB().
LeftJoin(h.readDB.SystemItemCards, h.readDB.SystemItemCards.ID.EqCol(h.readDB.UserItemCards.CardID)).
Select(h.readDB.UserItemCards.ID.Count().As("count"), h.readDB.SystemItemCards.Price.Sum().As("value")).
Where(h.readDB.UserItemCards.UserID.Eq(userID)).
Where(h.readDB.UserItemCards.Status.Eq(1)).
Scan(&cds)
rsp.CurrentAssets.ItemCardCount = cds.Count
rsp.CurrentAssets.ItemCardValue = cds.Value
// 4.5 持有次数卡
_ = h.repo.GetDbR().Raw("SELECT COALESCE(SUM(remaining), 0) FROM user_game_passes WHERE user_id = ? AND remaining > 0 AND (expired_at IS NULL OR expired_at > NOW())", userID).Scan(&rsp.CurrentAssets.GamePassCount).Error
// 4.6 持有游戏资格
_ = h.repo.GetDbR().Raw("SELECT COALESCE(SUM(available), 0) FROM user_game_tickets WHERE user_id = ?", userID).Scan(&rsp.CurrentAssets.GameTicketCount).Error
// 4.5 总资产估值
// 估值逻辑:积分余额 + 商品价值 + 优惠券价值 + 道具卡价值 + 次数卡(2元/次)
// 游戏资格不计入估值(购买其他商品赠送,无实际价值)
gamePassValue := rsp.CurrentAssets.GamePassCount * 200 // 估值2元/次
gameTicketValue := int64(0) // 游戏资格不计入估值
rsp.CurrentAssets.TotalAssetValue = rsp.CurrentAssets.PointsBalance +
rsp.CurrentAssets.InventoryValue +
rsp.CurrentAssets.CouponValue +
rsp.CurrentAssets.ItemCardValue +
gamePassValue +
gameTicketValue
// 4.6 累计盈亏比
if rsp.LifetimeStats.NetCashCost > 0 {
rsp.CurrentAssets.ProfitLossRatio = float64(rsp.CurrentAssets.TotalAssetValue) / float64(rsp.LifetimeStats.NetCashCost)
} else if rsp.CurrentAssets.TotalAssetValue > 0 {
rsp.CurrentAssets.ProfitLossRatio = 99.9 // 无成本但有资产
}
ctx.Payload(rsp)
}
}