bindbox-game/internal/api/admin/users_profit_loss.go
2026-02-27 00:08:02 +08:00

608 lines
19 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 (
"fmt"
"net/http"
"strconv"
"time"
"bindbox-game/internal/code"
"bindbox-game/internal/pkg/core"
"bindbox-game/internal/pkg/validation"
)
type userProfitLossRequest struct {
RangeType string `form:"rangeType"`
Granularity string `form:"granularity"`
}
type userProfitLossPoint struct {
Date string `json:"date"`
Cost int64 `json:"cost"` // 累计投入(已支付-已退款)
Value int64 `json:"value"` // 累计产出(当前资产快照)
Profit int64 `json:"profit"` // 净盈亏
Ratio float64 `json:"ratio"` // 盈亏比
Breakdown struct {
Products int64 `json:"products"`
Points int64 `json:"points"`
Cards int64 `json:"cards"`
Coupons int64 `json:"coupons"`
} `json:"breakdown"`
}
type userProfitLossResponse struct {
Granularity string `json:"granularity"`
List []userProfitLossPoint `json:"list"`
Summary struct {
TotalCost int64 `json:"total_cost"`
TotalValue int64 `json:"total_value"`
TotalProfit int64 `json:"total_profit"`
AvgRatio float64 `json:"avg_ratio"`
} `json:"summary"`
CurrentAssets struct {
Points int64 `json:"points"`
Products int64 `json:"products"`
Cards int64 `json:"cards"`
Coupons int64 `json:"coupons"`
Total int64 `json:"total"`
} `json:"currentAssets"`
}
func (h *handler) GetUserProfitLossTrend() 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(userProfitLossRequest)
if err := ctx.ShouldBindForm(req); err != nil {
ctx.AbortWithError(core.Error(http.StatusBadRequest, code.ParamBindError, validation.Error(err)))
return
}
start, end := parseRange(req.RangeType, "", "")
if req.RangeType == "all" {
u, _ := h.readDB.Users.WithContext(ctx.RequestContext()).Where(h.readDB.Users.ID.Eq(userID)).First()
if u != nil {
start = u.CreatedAt
} else {
start = time.Date(2025, 1, 1, 0, 0, 0, 0, time.Local)
}
}
gran := normalizeGranularity(req.Granularity)
buckets := buildBuckets(start, end, gran)
if len(buckets) > 1500 {
ctx.AbortWithError(core.Error(http.StatusBadRequest, code.ParamBindError, "范围过大"))
return
}
// --- 1. 获取当前资产快照(实时余额)---
var curAssets struct {
Points int64
Products int64
Cards int64
Coupons int64
}
_ = h.repo.GetDbR().Raw("SELECT COALESCE(SUM(points), 0) FROM user_points WHERE user_id = ? AND (valid_end IS NULL OR valid_end > NOW())", userID).Scan(&curAssets.Points).Error
_ = h.repo.GetDbR().Raw(`
SELECT COALESCE(SUM(COALESCE(NULLIF(ui.value_cents, 0), p.price, 0)), 0)
FROM user_inventory ui
LEFT JOIN products p ON p.id = ui.product_id
WHERE ui.user_id = ? AND ui.status = 1
`, userID).Scan(&curAssets.Products).Error
_ = h.repo.GetDbR().Raw("SELECT COALESCE(SUM(sc.price), 0) FROM user_item_cards uic LEFT JOIN system_item_cards sc ON sc.id = uic.card_id WHERE uic.user_id = ? AND uic.status = 1", userID).Scan(&curAssets.Cards).Error
_ = h.repo.GetDbR().Raw("SELECT COALESCE(SUM(balance_amount), 0) FROM user_coupons WHERE user_id = ? AND status = 1", userID).Scan(&curAssets.Coupons).Error
totalAssetValue := curAssets.Points + curAssets.Products + curAssets.Cards + curAssets.Coupons
// --- 2. 获取订单数据(仅 status=2 已支付) ---
// 注意:为了计算累计趋势,我们需要获取 start 之前的所有已支付订单总额作为基数
var baseCost int64 = 0
_ = h.repo.GetDbR().Raw(`
SELECT COALESCE(SUM(CASE
WHEN o.source_type = 4 OR o.order_no LIKE 'GP%' OR (o.actual_amount = 0 AND o.remark LIKE '%use_game_pass%')
THEN COALESCE(od.draw_count * a.price_draw, 0)
ELSE o.actual_amount + o.discount_amount
END), 0)
FROM orders o
LEFT JOIN (
SELECT l.order_id, COUNT(*) as draw_count, MAX(ai.activity_id) as activity_id
FROM activity_draw_logs l
JOIN activity_issues ai ON ai.id = l.issue_id
GROUP BY l.order_id
) od ON od.order_id = o.id
LEFT JOIN activities a ON a.id = od.activity_id
WHERE o.user_id = ? AND o.status = 2 AND o.source_type IN (2,3,4) AND o.created_at < ?
`, userID, start).Scan(&baseCost).Error
// 扣除历史退款 (如果有的话,此处简化处理,主要关注当前范围内的波动)
var baseRefund int64 = 0
_ = 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' AND pr.created_at < ?
`, userID, start).Scan(&baseRefund).Error
baseCost -= baseRefund
if baseCost < 0 {
baseCost = 0
}
type orderSpendRow struct {
CreatedAt time.Time
Spending int64
}
var orderRows []orderSpendRow
_ = h.repo.GetDbR().Raw(`
SELECT o.created_at,
CASE
WHEN o.source_type = 4 OR o.order_no LIKE 'GP%' OR (o.actual_amount = 0 AND o.remark LIKE '%use_game_pass%')
THEN COALESCE(od.draw_count * a.price_draw, 0)
ELSE o.actual_amount + o.discount_amount
END as spending
FROM orders o
LEFT JOIN (
SELECT l.order_id, COUNT(*) as draw_count, MAX(ai.activity_id) as activity_id
FROM activity_draw_logs l
JOIN activity_issues ai ON ai.id = l.issue_id
GROUP BY l.order_id
) od ON od.order_id = o.id
LEFT JOIN activities a ON a.id = od.activity_id
WHERE o.user_id = ? AND o.status = 2 AND o.source_type IN (2,3,4) AND o.created_at BETWEEN ? AND ?
`, userID, start, end).Scan(&orderRows).Error
// 获取当前范围内的退款
type refundInfo struct {
Amount int64
CreatedAt time.Time
}
var refunds []refundInfo
_ = h.repo.GetDbR().Raw(`
SELECT pr.amount_refund as amount, pr.created_at
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' AND pr.created_at BETWEEN ? AND ?
`, userID, start, end).Scan(&refunds).Error
// --- 3. 按时间分桶计算 ---
list := make([]userProfitLossPoint, len(buckets))
inBucket := func(t time.Time, b bucket) bool {
return (t.After(b.Start) || t.Equal(b.Start)) && t.Before(b.End)
}
cumulativeCost := baseCost
for i, b := range buckets {
p := &list[i]
p.Date = b.Label
// 计算该时间段内的净投入变化
var periodDelta int64 = 0
for _, o := range orderRows {
if inBucket(o.CreatedAt, b) {
periodDelta += o.Spending
}
}
for _, r := range refunds {
if inBucket(r.CreatedAt, b) {
periodDelta -= r.Amount
}
}
cumulativeCost += periodDelta
if cumulativeCost < 0 {
cumulativeCost = 0
}
p.Cost = cumulativeCost
// 产出值:当前资产是一个存量值。
// 理想逻辑是回溯各时间点的余额,简化逻辑下:
// 如果该点还没有在该范围内发生过任何投入且没有基数则显示0否则显示当前快照值。
// 这里我们统一显示当前快照,但在前端图表上它会是一条水平线或阶梯线。
p.Value = totalAssetValue
p.Breakdown.Points = curAssets.Points
p.Breakdown.Products = curAssets.Products
p.Breakdown.Cards = curAssets.Cards
p.Breakdown.Coupons = curAssets.Coupons
p.Profit = p.Value - p.Cost
if p.Cost > 0 {
p.Ratio = float64(p.Value) / float64(p.Cost)
} else if p.Value > 0 {
p.Ratio = 99.9
}
}
// 汇总数据
var totalCost int64 = 0
_ = h.repo.GetDbR().Raw(`
SELECT COALESCE(SUM(CASE
WHEN o.source_type = 4 OR o.order_no LIKE 'GP%' OR (o.actual_amount = 0 AND o.remark LIKE '%use_game_pass%')
THEN COALESCE(od.draw_count * a.price_draw, 0)
ELSE o.actual_amount + o.discount_amount
END), 0)
FROM orders o
LEFT JOIN (
SELECT l.order_id, COUNT(*) as draw_count, MAX(ai.activity_id) as activity_id
FROM activity_draw_logs l
JOIN activity_issues ai ON ai.id = l.issue_id
GROUP BY l.order_id
) od ON od.order_id = o.id
LEFT JOIN activities a ON a.id = od.activity_id
WHERE o.user_id = ? AND o.status = 2 AND o.source_type IN (2,3,4)
`, userID).Scan(&totalCost).Error
var totalRefund int64 = 0
_ = 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(&totalRefund).Error
finalNetCost := totalCost - totalRefund
if finalNetCost < 0 {
finalNetCost = 0
}
resp := userProfitLossResponse{
Granularity: gran,
List: list,
}
resp.Summary.TotalCost = finalNetCost
resp.Summary.TotalValue = totalAssetValue
resp.Summary.TotalProfit = totalAssetValue - finalNetCost
if finalNetCost > 0 {
resp.Summary.AvgRatio = float64(totalAssetValue) / float64(finalNetCost)
} else if totalAssetValue > 0 {
resp.Summary.AvgRatio = 99.9
}
resp.CurrentAssets.Points = curAssets.Points
resp.CurrentAssets.Products = curAssets.Products
resp.CurrentAssets.Cards = curAssets.Cards
resp.CurrentAssets.Coupons = curAssets.Coupons
resp.CurrentAssets.Total = totalAssetValue
ctx.Payload(resp)
}
}
// 盈亏明细请求
type profitLossDetailsRequest struct {
Page int `form:"page"`
PageSize int `form:"page_size"`
RangeType string `form:"rangeType"`
}
// 盈亏明细项
type profitLossDetailItem struct {
OrderID int64 `json:"order_id"`
OrderNo string `json:"order_no"`
CreatedAt string `json:"created_at"`
SourceType int32 `json:"source_type"` // 来源类型 1商城 2抽奖 3系统
ActivityName string `json:"activity_name"` // 活动名称
ActualAmount int64 `json:"actual_amount"` // 实际支付金额(分)
RefundAmount int64 `json:"refund_amount"` // 退款金额(分)
NetCost int64 `json:"net_cost"` // 净投入(分)
PrizeValue int64 `json:"prize_value"` // 获得奖品价值(分)
PrizeName string `json:"prize_name"` // 奖品名称
PointsEarned int64 `json:"points_earned"` // 获得积分
PointsValue int64 `json:"points_value"` // 积分价值(分)
CouponUsedValue int64 `json:"coupon_used_value"` // 使用优惠券价值(分)
CouponUsedName string `json:"coupon_used_name"` // 使用的优惠券名称
ItemCardUsed string `json:"item_card_used"` // 使用的道具卡名称
ItemCardValue int64 `json:"item_card_value"` // 道具卡价值(分)
NetProfit int64 `json:"net_profit"` // 净盈亏
}
// 盈亏明细响应
type profitLossDetailsResponse struct {
Page int `json:"page"`
PageSize int `json:"page_size"`
Total int64 `json:"total"`
List []profitLossDetailItem `json:"list"`
Summary struct {
TotalCost int64 `json:"total_cost"`
TotalValue int64 `json:"total_value"`
TotalProfit int64 `json:"total_profit"`
} `json:"summary"`
}
// GetUserProfitLossDetails 获取用户盈亏明细
// @Summary 获取用户盈亏明细
// @Description 获取用户每笔订单的详细盈亏信息
// @Tags 管理端.用户
// @Accept json
// @Produce json
// @Param user_id path integer true "用户ID"
// @Param page query int false "页码" default(1)
// @Param page_size query int false "每页数量" default(20)
// @Param rangeType query string false "时间范围" default("all")
// @Success 200 {object} profitLossDetailsResponse
// @Failure 400 {object} code.Failure
// @Router /api/admin/users/{user_id}/stats/profit_loss_details [get]
// @Security LoginVerifyToken
func (h *handler) GetUserProfitLossDetails() 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(profitLossDetailsRequest)
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
}
// 解析时间范围
start, end := parseRange(req.RangeType, "", "")
if req.RangeType == "all" {
u, _ := h.readDB.Users.WithContext(ctx.RequestContext()).Where(h.readDB.Users.ID.Eq(userID)).First()
if u != nil {
start = u.CreatedAt
} else {
start = time.Date(2025, 1, 1, 0, 0, 0, 0, time.Local)
}
}
// 查询订单总数
orderQ := h.readDB.Orders.WithContext(ctx.RequestContext()).ReadDB().
Where(h.readDB.Orders.UserID.Eq(userID)).
Where(h.readDB.Orders.Status.Eq(2)).
Where(h.readDB.Orders.CreatedAt.Gte(start)).
Where(h.readDB.Orders.CreatedAt.Lte(end))
total, err := orderQ.Count()
if err != nil {
ctx.AbortWithError(core.Error(http.StatusBadRequest, 20108, err.Error()))
return
}
// 分页查询订单
orders, err := orderQ.Order(h.readDB.Orders.CreatedAt.Desc()).
Offset((req.Page - 1) * req.PageSize).Limit(req.PageSize).Find()
if err != nil {
ctx.AbortWithError(core.Error(http.StatusBadRequest, 20108, err.Error()))
return
}
// 收集订单ID
orderIDs := make([]int64, len(orders))
orderNos := make([]string, len(orders))
for i, o := range orders {
orderIDs[i] = o.ID
orderNos[i] = o.OrderNo
}
// 批量查询退款信息
refundMap := make(map[string]int64)
if len(orderNos) > 0 {
type refundRow struct {
OrderNo string
Amount int64
}
var refunds []refundRow
_ = h.repo.GetDbR().Raw(`
SELECT order_no, COALESCE(SUM(amount_refund), 0) as amount
FROM payment_refunds
WHERE order_no IN ? AND status = 'SUCCESS'
GROUP BY order_no
`, orderNos).Scan(&refunds).Error
for _, r := range refunds {
refundMap[r.OrderNo] = r.Amount
}
}
// 批量查询库存价值(获得的奖品)
prizeValueMap := make(map[int64]int64)
prizeNameMap := make(map[int64]string)
if len(orderIDs) > 0 {
type prizeRow struct {
OrderID int64
Value int64
Name string
}
var prizes []prizeRow
if err := h.repo.GetDbR().Raw(`
SELECT ui.order_id,
CAST(COALESCE(SUM(COALESCE(NULLIF(ui.value_cents, 0), activity_reward_settings.price_snapshot_cents, p.price, 0) * GREATEST(COALESCE(system_item_cards.reward_multiplier_x1000, 1000), 1000) / 1000), 0) AS SIGNED) as value,
GROUP_CONCAT(p.name SEPARATOR ', ') as name
FROM user_inventory ui
LEFT JOIN products p ON p.id = ui.product_id
LEFT JOIN activity_reward_settings ON activity_reward_settings.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 ON system_item_cards.id = uic.card_id
WHERE ui.order_id IN ?
GROUP BY ui.order_id
`, orderIDs).Scan(&prizes).Error; err != nil {
h.logger.Error(fmt.Sprintf("GetUserProfitLoss detail prize query error: %v", err))
}
for _, p := range prizes {
prizeValueMap[p.OrderID] = p.Value
prizeNameMap[p.OrderID] = p.Name
}
}
// 批量查询使用的优惠券
couponValueMap := make(map[int64]int64)
couponNameMap := make(map[int64]string)
if len(orderIDs) > 0 {
type couponRow struct {
OrderID int64
Value int64
Name string
}
var coupons []couponRow
_ = h.repo.GetDbR().Raw(`
SELECT ucu.order_id, COALESCE(SUM(ABS(ucu.change_amount)), 0) as value,
GROUP_CONCAT(DISTINCT sc.name SEPARATOR ', ') as name
FROM user_coupon_usage ucu
LEFT JOIN user_coupons uc ON uc.id = ucu.user_coupon_id
LEFT JOIN system_coupons sc ON sc.id = uc.coupon_id
WHERE ucu.order_id IN ?
GROUP BY ucu.order_id
`, orderIDs).Scan(&coupons).Error
for _, c := range coupons {
couponValueMap[c.OrderID] = c.Value
couponNameMap[c.OrderID] = c.Name
}
}
// 批量查询活动信息
activityNameMap := make(map[int64]string)
if len(orderIDs) > 0 {
type actRow struct {
OrderID int64
ActivityName string
}
var acts []actRow
_ = h.repo.GetDbR().Raw(`
SELECT o.id as order_id, a.name as activity_name
FROM orders o
LEFT JOIN activities a ON a.id = o.activity_id
WHERE o.id IN ? AND o.activity_id > 0
`, orderIDs).Scan(&acts).Error
for _, a := range acts {
activityNameMap[a.OrderID] = a.ActivityName
}
}
// 批量计算订单统一支出(普通单=支付+优惠券;次卡单=次卡价值)
orderSpendingMap := make(map[int64]int64)
if len(orderIDs) > 0 {
type spendRow struct {
OrderID int64
Spending int64
}
var spends []spendRow
_ = h.repo.GetDbR().Raw(`
SELECT o.id as order_id,
CASE
WHEN o.source_type = 4 OR o.order_no LIKE 'GP%' OR (o.actual_amount = 0 AND o.remark LIKE '%use_game_pass%')
THEN COALESCE(od.draw_count * a.price_draw, 0)
ELSE o.actual_amount + o.discount_amount
END as spending
FROM orders o
LEFT JOIN (
SELECT l.order_id, COUNT(*) as draw_count, MAX(ai.activity_id) as activity_id
FROM activity_draw_logs l
JOIN activity_issues ai ON ai.id = l.issue_id
GROUP BY l.order_id
) od ON od.order_id = o.id
LEFT JOIN activities a ON a.id = od.activity_id
WHERE o.id IN ?
`, orderIDs).Scan(&spends).Error
for _, s := range spends {
orderSpendingMap[s.OrderID] = s.Spending
}
}
// 组装明细数据
list := make([]profitLossDetailItem, len(orders))
var totalCost, totalValue int64
for i, o := range orders {
refund := refundMap[o.OrderNo]
prizeValue := prizeValueMap[o.ID]
couponValue := couponValueMap[o.ID]
spending := orderSpendingMap[o.ID]
if spending == 0 {
spending = o.ActualAmount + o.DiscountAmount
}
netCost := spending - refund
if netCost < 0 {
netCost = 0
}
netProfit := prizeValue - netCost
list[i] = profitLossDetailItem{
OrderID: o.ID,
OrderNo: o.OrderNo,
CreatedAt: o.CreatedAt.Format("2006-01-02 15:04:05"),
SourceType: o.SourceType,
ActivityName: activityNameMap[o.ID],
ActualAmount: o.ActualAmount,
RefundAmount: refund,
NetCost: netCost,
PrizeValue: prizeValue,
PrizeName: prizeNameMap[o.ID],
PointsEarned: 0, // 简化处理
PointsValue: 0,
CouponUsedValue: couponValue,
CouponUsedName: couponNameMap[o.ID],
ItemCardUsed: "", // 从订单备注中解析
ItemCardValue: 0,
NetProfit: netProfit,
}
// 解析道具卡信息(从订单备注)
if o.Remark != "" {
list[i].ItemCardUsed = parseItemCardFromRemark(o.Remark)
}
totalCost += netCost
totalValue += prizeValue
}
resp := profitLossDetailsResponse{
Page: req.Page,
PageSize: req.PageSize,
Total: total,
List: list,
}
resp.Summary.TotalCost = totalCost
resp.Summary.TotalValue = totalValue
resp.Summary.TotalProfit = totalValue - totalCost
ctx.Payload(resp)
}
}
// 从订单备注中解析道具卡信息
func parseItemCardFromRemark(remark string) string {
// 格式: itemcard:xxx|...
if len(remark) == 0 {
return ""
}
idx := 0
for i := 0; i < len(remark); i++ {
if remark[i:] == "itemcard:" || (i+9 <= len(remark) && remark[i:i+9] == "itemcard:") {
idx = i
break
}
}
if idx == 0 && len(remark) < 9 {
return ""
}
if idx+9 >= len(remark) {
return ""
}
seg := remark[idx+9:]
// 找到 | 分隔符
end := len(seg)
for i := 0; i < len(seg); i++ {
if seg[i] == '|' {
end = i
break
}
}
return seg[:end]
}