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