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) } }