bindbox-game/internal/api/admin/users_profile.go
Zuncle 58fd926b46 fix(finance): 统一收益统计口径,修复多处数据计算错误
1. Revenue 口径统一为 actual_amount(真实现金到账)
   - 优惠券(discount_amount)和积分(points_amount)是平台免费发放的营销补贴,
     不算收入,改为展示字段
   - 涉及: profit_metrics.go, dashboard_spending.go, users_profit_loss.go,
     dashboard_user_spending.go, activity_rankings_admin.go

2. Cost 口径统一为奖品库存价值
   - 删除 finance service 中的积分成本扫描(Step 3)和优惠券成本扫描(Step 4)
   - 之前优惠券同时算在收入和成本两侧,导致利润被人为压低
   - 涉及: query_user.go, query_activity.go

3. 统一 value_cents fallback chain
   - finance service 改为与排行榜一致的三级回退:
     COALESCE(NULLIF(value_cents,0), price_snapshot_cents, products.price, 0)
   - 涉及: query_user.go, query_activity.go

4. 活动盈亏收入统一到 finance service
   - 删除 dashboard_activity.go 自有的 revenue SQL(含比例分摊逻辑)
   - 收入和成本统一由 finance.Service.QueryActivityProfitLoss() 提供
   - 修复日志明细 profit:道具卡倍率改用 ComputePrizeCostWithMultiplier

5. finance service 新增展示字段
   - ProfitLossDetail 增加 CouponDiscount, PointsDiscount, GamePassValue
   - 不参与 Revenue/Cost/Profit 计算,仅供前端展示营销补贴明细

6. 修复对对碰次卡订单 discount_amount 数据污染
   - matching_game_app.go 次卡下单时 DiscountAmount 错误设为活动全价
   - 改为 0(次卡支付不涉及优惠券)
   - 附带历史数据修复 migration SQL

7. 排除已分解奖品的成本重复计算
   - 用户可以把奖品分解成积分再兑换新商品,导致同一份价值被计算两次
   - 所有库存查询增加排除条件: status=3 且 remark 含 redeemed_points 或 batch_redeemed
   - 涉及 6 个文件的库存成本/资产查询

8. 排行榜详情抽屉限定活动范围
   - prize 查询增加 activity_id > 0 过滤,排除积分兑换/转入/合成等非活动产出
   - 使排行榜与其详情抽屉口径一致

修改文件(12个):
- internal/service/finance/profit_metrics.go
- internal/service/finance/query_user.go
- internal/service/finance/query_activity.go
- internal/service/finance/types.go
- internal/api/admin/dashboard_activity.go
- internal/api/admin/dashboard_spending.go
- internal/api/admin/dashboard_user_spending.go
- internal/api/admin/users_profit_loss.go
- internal/api/admin/users_profile.go
- internal/api/admin/activity_rankings_admin.go
- internal/api/activity/matching_game_app.go
- migrations/20260325_fix_matching_gamepass_discount.sql
2026-03-26 00:01:17 +08:00

275 lines
10 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,
CAST(COALESCE(SUM(
COALESCE(NULLIF(ui.value_cents, 0), ars.price_snapshot_cents, p.price, 0)
* GREATEST(COALESCE(sic.reward_multiplier_x1000, 1000), 1000) / 1000
), 0) AS SIGNED) as value
FROM user_inventory ui
LEFT JOIN products p ON p.id = ui.product_id
LEFT JOIN activity_reward_settings ars ON ars.id = ui.reward_id
LEFT JOIN orders o ON o.id = ui.order_id
LEFT JOIN user_item_cards uic ON uic.id = o.item_card_id
LEFT JOIN system_item_cards sic ON sic.id = uic.card_id
WHERE ui.user_id = ? AND ui.status IN (1, 3)
AND COALESCE(ui.remark, '') NOT LIKE '%%void%%'
AND NOT (ui.status = 3 AND (COALESCE(ui.remark, '') LIKE '%%redeemed_points%%' OR COALESCE(ui.remark, '') LIKE '%%batch_redeemed%%'))
`, 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)
}
}