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

161 lines
4.8 KiB
Go

package admin
import (
"net/http"
"strconv"
"bindbox-game/internal/code"
"bindbox-game/internal/pkg/core"
"bindbox-game/internal/pkg/validation"
)
type activityRankingsRequest struct {
SortBy string `form:"sort_by"`
Page int `form:"page"`
PageSize int `form:"page_size"`
}
type activityRankingItem struct {
Rank int64 `json:"rank"`
UserID int64 `json:"user_id"`
Nickname string `json:"nickname"`
Avatar string `json:"avatar"`
TotalAmount int64 `json:"total_amount"`
OrderCount int64 `json:"order_count"`
}
type activityRankingsResponse struct {
Page int `json:"page"`
PageSize int `json:"page_size"`
Total int64 `json:"total"`
List []activityRankingItem `json:"list"`
}
// GetActivityRankings 获取活动内用户消费/订单排行榜
// @Summary 活动排行榜
// @Description 按活动维度统计用户消费总额与订单数排行榜
// @Tags 管理端.活动
// @Accept json
// @Produce json
// @Param activity_id path int true "活动ID"
// @Param sort_by query string false "排序字段: amount|orders"
// @Param page query int false "页码" default(1)
// @Param page_size query int false "每页条数(最大100)" default(20)
// @Success 200 {object} activityRankingsResponse
// @Failure 400 {object} code.Failure
// @Router /api/admin/activities/{activity_id}/rankings [get]
// @Security LoginVerifyToken
func (h *handler) GetActivityRankings() core.HandlerFunc {
return func(ctx core.Context) {
activityID, err := strconv.ParseInt(ctx.Param("activity_id"), 10, 64)
if err != nil || activityID <= 0 {
ctx.AbortWithError(core.Error(http.StatusBadRequest, code.ParamBindError, "无效的活动ID"))
return
}
req := new(activityRankingsRequest)
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
}
if req.PageSize > 100 {
req.PageSize = 100
}
if req.SortBy != "orders" {
req.SortBy = "amount"
}
baseQuery := h.repo.GetDbR().WithContext(ctx.RequestContext()).
Table("orders").
Joins(`
JOIN (
SELECT DISTINCT activity_draw_logs.order_id, activity_issues.activity_id
FROM activity_draw_logs
JOIN activity_issues ON activity_issues.id = activity_draw_logs.issue_id
) AS order_activities ON order_activities.order_id = orders.id
`).
Where("order_activities.activity_id = ?", activityID).
Where("orders.status = ?", 2)
userSubQuery := baseQuery.
Select("orders.user_id").
Group("orders.user_id")
var total int64
if err := h.repo.GetDbR().WithContext(ctx.RequestContext()).
Table("(?) AS ranked_users", userSubQuery).
Count(&total).Error; err != nil {
ctx.AbortWithError(core.Error(http.StatusInternalServerError, code.ServerError, err.Error()))
return
}
type rankingRow struct {
UserID int64
Nickname string
Avatar string
TotalAmount int64
OrderCount int64
}
var rows []rankingRow
listQuery := h.repo.GetDbR().WithContext(ctx.RequestContext()).
Table("orders").
Joins(`
JOIN (
SELECT DISTINCT activity_draw_logs.order_id, activity_issues.activity_id
FROM activity_draw_logs
JOIN activity_issues ON activity_issues.id = activity_draw_logs.issue_id
) AS order_activities ON order_activities.order_id = orders.id
`).
Joins("LEFT JOIN users ON users.id = orders.user_id").
Where("order_activities.activity_id = ?", activityID).
Where("orders.status = ?", 2).
Select(`
orders.user_id,
COALESCE(users.nickname, '') AS nickname,
COALESCE(users.avatar, '') AS avatar,
CAST(SUM(orders.actual_amount) AS SIGNED) AS total_amount,
COUNT(DISTINCT orders.id) AS order_count
`).
Group("orders.user_id, users.nickname, users.avatar")
if req.SortBy == "orders" {
listQuery = listQuery.Order("order_count DESC").Order("total_amount DESC").Order("orders.user_id ASC")
} else {
listQuery = listQuery.Order("total_amount DESC").Order("order_count DESC").Order("orders.user_id ASC")
}
offset := (req.Page - 1) * req.PageSize
if err := listQuery.Offset(offset).Limit(req.PageSize).Scan(&rows).Error; err != nil {
ctx.AbortWithError(core.Error(http.StatusInternalServerError, code.ServerError, err.Error()))
return
}
res := activityRankingsResponse{
Page: req.Page,
PageSize: req.PageSize,
Total: total,
List: make([]activityRankingItem, 0, len(rows)),
}
for i, row := range rows {
res.List = append(res.List, activityRankingItem{
Rank: int64(offset + i + 1),
UserID: row.UserID,
Nickname: row.Nickname,
Avatar: row.Avatar,
TotalAmount: row.TotalAmount,
OrderCount: row.OrderCount,
})
}
ctx.Payload(res)
}
}