bindbox-game/internal/api/admin/users_profit_loss.go

230 lines
7.6 KiB
Go
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"
"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(p.price), 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.readDB.Orders.WithContext(ctx.RequestContext()).ReadDB().
Select(h.readDB.Orders.ActualAmount.Sum()).
Where(h.readDB.Orders.UserID.Eq(userID)).
Where(h.readDB.Orders.Status.Eq(2)).
Where(h.readDB.Orders.CreatedAt.Lt(start)).
Scan(&baseCost)
// 扣除历史退款 (如果有的话,此处简化处理,主要关注当前范围内的波动)
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
}
orderRows, _ := 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)).
Find()
// 获取当前范围内的退款
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.ActualAmount
}
}
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.readDB.Orders.WithContext(ctx.RequestContext()).ReadDB().
Select(h.readDB.Orders.ActualAmount.Sum()).
Where(h.readDB.Orders.UserID.Eq(userID)).
Where(h.readDB.Orders.Status.Eq(2)).
Scan(&totalCost)
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)
}
}