230 lines
7.6 KiB
Go
230 lines
7.6 KiB
Go
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)
|
||
}
|
||
}
|