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
161 lines
4.8 KiB
Go
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)
|
|
}
|
|
}
|