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

174 lines
5.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"`
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 已支付未退款)---
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()
// --- 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)
}
var cumulativeCost int64 = 0
for i, b := range buckets {
p := &list[i]
p.Date = b.Label
// 计算该时间段内的支出
var periodCost int64 = 0
for _, o := range orderRows {
if inBucket(o.CreatedAt, b) {
periodCost += o.ActualAmount
}
}
cumulativeCost += periodCost
p.Cost = periodCost
// 使用当前资产快照作为产出值最后一个桶显示完整值其他桶按比例或显示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
for _, o := range orderRows {
totalCost += o.ActualAmount
}
// 最后一个桶使用累计成本
if len(list) > 0 {
lastIdx := len(list) - 1
// 汇总数据:使用累计成本和当前资产值
list[lastIdx].Cost = totalCost
list[lastIdx].Value = totalAssetValue
list[lastIdx].Profit = totalAssetValue - totalCost
if totalCost > 0 {
list[lastIdx].Ratio = float64(totalAssetValue) / float64(totalCost)
} else if totalAssetValue > 0 {
list[lastIdx].Ratio = 99.9
}
}
ctx.Payload(userProfitLossResponse{
Granularity: gran,
List: list,
CurrentAssets: struct {
Points int64 `json:"points"`
Products int64 `json:"products"`
Cards int64 `json:"cards"`
Coupons int64 `json:"coupons"`
Total int64 `json:"total"`
}{
Points: curAssets.Points,
Products: curAssets.Products,
Cards: curAssets.Cards,
Coupons: curAssets.Coupons,
Total: totalAssetValue,
},
})
}
}