bindbox-game/internal/api/admin/dashboard_user_spending.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

353 lines
12 KiB
Go
Executable File

package admin
import (
"fmt"
"net/http"
"strconv"
"strings"
"time"
"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"
)
type userSpendingRequest struct {
RangeType string `form:"rangeType"`
StartDate string `form:"start"`
EndDate string `form:"end"`
}
// userActivitySpending 用户在具体活动实例上的消费统计
type userActivitySpending struct {
ActivityID int64 `json:"activity_id"`
ActivityName string `json:"activity_name"`
CategoryID int64 `json:"category_id"`
CategoryName string `json:"category_name"` // 一番赏/盲盒/对对碰/直播间
Spending int64 `json:"spending"` // 消费金额(分)
PrizeValue int64 `json:"prize_value"` // 产出价值(分)
Profit int64 `json:"profit"` // 收益(分)
OrderCount int64 `json:"order_count"` // 订单数
}
type userSpendingResponse struct {
UserID int64 `json:"user_id"`
Nickname string `json:"nickname"`
Avatar string `json:"avatar"`
TotalSpend int64 `json:"total_spend"`
TotalPrize int64 `json:"total_prize"`
TotalProfit int64 `json:"total_profit"`
TotalOrders int64 `json:"total_orders"`
Activities []userActivitySpending `json:"activities"`
}
var categoryNames = map[int64]string{
1: "一番赏",
2: "盲盒/无限",
3: "对对碰",
}
func (h *handler) GetUserSpendingDashboard() 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
}
req := new(userSpendingRequest)
if err := ctx.ShouldBindForm(req); err != nil {
ctx.AbortWithError(core.Error(http.StatusBadRequest, code.ParamBindError, validation.Error(err)))
return
}
var start, end time.Time
hasRange := req.RangeType != "" && req.RangeType != "all"
if hasRange {
start, end = parseRange(req.RangeType, req.StartDate, req.EndDate)
}
db := h.repo.GetDbR().WithContext(ctx.RequestContext())
rsp := &userSpendingResponse{UserID: userID}
// 获取用户基本信息
user, _ := h.readDB.Users.WithContext(ctx.RequestContext()).ReadDB().Where(h.readDB.Users.ID.Eq(userID)).First()
if user != nil {
rsp.Nickname = user.Nickname
rsp.Avatar = user.Avatar
}
// 1. 按活动实例统计消费
type activityStat struct {
ActivityID int64
ActivityName string
CategoryID int64
Spending int64
OrderCount int64
}
var actStats []activityStat
query := db.Table(model.TableNameOrders).
Joins(`LEFT JOIN (
SELECT activity_draw_logs.order_id, activity_issues.activity_id, COUNT(*) as draw_count
FROM activity_draw_logs
JOIN activity_issues ON activity_issues.id = activity_draw_logs.issue_id
GROUP BY activity_draw_logs.order_id, activity_issues.activity_id
) as order_activity_draws ON order_activity_draws.order_id = orders.id`).
Joins(`LEFT JOIN (
SELECT order_id, COUNT(*) as total_count
FROM activity_draw_logs
GROUP BY order_id
) as order_total_draws ON order_total_draws.order_id = orders.id`).
Joins("LEFT JOIN activities ON activities.id = order_activity_draws.activity_id").
Where("orders.user_id = ?", userID).
Where("orders.status = ?", 2)
if hasRange {
query = query.Where("orders.created_at >= ?", start).Where("orders.created_at <= ?", end)
}
if err := query.Select(`
COALESCE(activities.id, 0) as activity_id,
COALESCE(activities.name, '其他') as activity_name,
COALESCE(activities.activity_category_id, 0) as category_id,
CAST(ROUND(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(order_activity_draws.draw_count * activities.price_draw, 0)
ELSE COALESCE(orders.actual_amount * order_activity_draws.draw_count / NULLIF(order_total_draws.total_count, 0), 0)
END), 0) AS SIGNED) as spending,
COUNT(DISTINCT orders.id) as order_count
`).
Group("COALESCE(activities.id, 0)").
Order("spending DESC").
Scan(&actStats).Error; err != nil {
h.logger.Error(fmt.Sprintf("UserSpending SQL error: %v", err))
ctx.AbortWithError(core.Error(http.StatusBadRequest, 21030, err.Error()))
return
}
// 2. 按活动实例统计产出价值
type prizeStat struct {
ActivityID int64
PrizeValue int64
}
var prizeStats []prizeStat
prizeQuery := db.Table(model.TableNameUserInventory).
Joins("LEFT JOIN orders ON orders.id = user_inventory.order_id").
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 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 = ?", userID).
Where("user_inventory.status IN ?", []int{1, 3}).
Where("COALESCE(user_inventory.remark, '') NOT LIKE ?", "%void%").
Where("NOT (user_inventory.status = 3 AND (COALESCE(user_inventory.remark, '') LIKE ? OR COALESCE(user_inventory.remark, '') LIKE ?))", "%redeemed_points%", "%batch_redeemed%").
Where("COALESCE(NULLIF(user_inventory.activity_id, 0), cost_issues.activity_id, 0) > 0")
if hasRange {
prizeQuery = prizeQuery.Where("user_inventory.created_at >= ?", start).Where("user_inventory.created_at <= ?", end)
}
if err := prizeQuery.Select(`
COALESCE(NULLIF(user_inventory.activity_id, 0), cost_issues.activity_id, 0) as activity_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 prize_value
`).
Group("COALESCE(NULLIF(user_inventory.activity_id, 0), cost_issues.activity_id, 0)").
Scan(&prizeStats).Error; err != nil {
h.logger.Error(fmt.Sprintf("GetUserSpendingDashboard prize stats error: %v", err))
}
prizeMap := make(map[int64]int64)
for _, p := range prizeStats {
prizeMap[p.ActivityID] = p.PrizeValue
}
// 3. 直播间消费统计
type livestreamStat struct {
ActivityID int64
ActivityName string
Spending int64
OrderCount int64
}
var lsStats []livestreamStat
lsQuery := db.Table("douyin_orders").
Joins("LEFT JOIN livestream_activities ON livestream_activities.id = douyin_orders.livestream_activity_id").
Select(`
COALESCE(douyin_orders.livestream_activity_id, 0) as activity_id,
COALESCE(livestream_activities.name, '直播间') as activity_name,
SUM(actual_pay_amount) as spending,
COUNT(*) as order_count
`).
Where("CAST(local_user_id AS SIGNED) = ?", userID).
Where("local_user_id != '' AND local_user_id != '0'")
if hasRange {
lsQuery = lsQuery.Where("douyin_orders.created_at >= ?", start).Where("douyin_orders.created_at <= ?", end)
}
lsQuery.Group("COALESCE(douyin_orders.livestream_activity_id, 0)").Scan(&lsStats)
// 直播间产出
type lsPrizeStat struct {
ActivityID int64
PrizeValue int64
}
var lsPrizeStats []lsPrizeStat
type lsLog struct {
ActivityID int64
ShopOrderID string
PrizeID int64
}
var lsLogs []lsLog
lsLogQuery := db.Table("livestream_draw_logs").
Select("livestream_activity_id as activity_id, shop_order_id, prize_id").
Where("local_user_id = ?", userID).
Where("is_refunded = 0").
Where("prize_id > 0")
if hasRange {
lsLogQuery = lsLogQuery.Where("created_at >= ?", start).Where("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 {
ValueCents int64
Remark string
}
var invRows []invRow
invQ := h.repo.GetDbR().Table("user_inventory").
Select("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_id = ?", userID).
Where("status IN (1,3)").
Where("COALESCE(user_inventory.remark, '') NOT LIKE ?", "%void%").
Where("NOT (user_inventory.status = 3 AND (COALESCE(user_inventory.remark, '') LIKE ? OR COALESCE(user_inventory.remark, '') LIKE ?))", "%redeemed_points%", "%batch_redeemed%")
if hasRange {
invQ = invQ.Where("created_at >= ?", start.Add(-2*time.Hour)).Where("created_at <= ?", end.Add(24*time.Hour))
}
_ = invQ.Scan(&invRows).Error
lsByKey := make(map[string][]lsLog)
for _, l := range lsLogs {
key := fmt.Sprintf("%d|%s", l.ActivityID, l.ShopOrderID)
lsByKey[key] = append(lsByKey[key], l)
}
prizeByActivity := make(map[int64]int64)
for _, logs := range lsByKey {
if len(logs) == 0 {
continue
}
aid := logs[0].ActivityID
shopOrderID := logs[0].ShopOrderID
var snapshotSum int64
if shopOrderID != "" {
for _, inv := range invRows {
if strings.Contains(inv.Remark, shopOrderID) {
snapshotSum += inv.ValueCents
}
}
}
if snapshotSum > 0 {
prizeByActivity[aid] += snapshotSum
continue
}
for _, l := range logs {
prizeByActivity[aid] += prizeCostMap[l.PrizeID]
}
}
for aid, val := range prizeByActivity {
lsPrizeStats = append(lsPrizeStats, lsPrizeStat{
ActivityID: aid,
PrizeValue: val,
})
}
}
lsPrizeMap := make(map[int64]int64)
for _, p := range lsPrizeStats {
lsPrizeMap[p.ActivityID] = p.PrizeValue
}
// 4. 组装结果
activities := make([]userActivitySpending, 0)
var totalSpend, totalPrize, totalOrders int64
for _, s := range actStats {
prize := prizeMap[s.ActivityID]
catName := categoryNames[s.CategoryID]
if catName == "" {
catName = "其他"
}
item := userActivitySpending{
ActivityID: s.ActivityID,
ActivityName: s.ActivityName,
CategoryID: s.CategoryID,
CategoryName: catName,
Spending: s.Spending,
PrizeValue: prize,
Profit: func() int64 { p, _ := financesvc.ComputeProfit(s.Spending, prize); return p }(),
OrderCount: s.OrderCount,
}
activities = append(activities, item)
totalSpend += s.Spending
totalPrize += prize
totalOrders += s.OrderCount
}
// 追加直播间活动
for _, ls := range lsStats {
prize := lsPrizeMap[ls.ActivityID]
item := userActivitySpending{
ActivityID: ls.ActivityID + 100000, // 避免和普通活动 ID 冲突
ActivityName: ls.ActivityName,
CategoryID: 4,
CategoryName: "直播间",
Spending: ls.Spending,
PrizeValue: prize,
Profit: func() int64 { p, _ := financesvc.ComputeProfit(ls.Spending, prize); return p }(),
OrderCount: ls.OrderCount,
}
activities = append(activities, item)
totalSpend += ls.Spending
totalPrize += prize
totalOrders += ls.OrderCount
}
rsp.TotalSpend = totalSpend
rsp.TotalPrize = totalPrize
rsp.TotalProfit, _ = financesvc.ComputeProfit(totalSpend, totalPrize)
rsp.TotalOrders = totalOrders
rsp.Activities = activities
ctx.Payload(rsp)
}
}