package controller import ( "math" "net/http" "sort" "strings" "time" "ezops/model" "github.com/gin-gonic/gin" ) // billingAnalysisItem 表示单月一个费用分类的汇总值。 // Logan.Li type billingAnalysisItem struct { Name string `json:"name"` Cost float64 `json:"cost"` Percent float64 `json:"percent"` } // billingAnalysisMonth 表示堆叠图和明细矩阵共用的月度数据。 // Logan.Li type billingAnalysisMonth struct { Month string `json:"month"` Total float64 `json:"total"` Items []billingAnalysisItem `json:"items"` } // billingAttribution 表示最近两个月可复核的费用变化归因。 // Logan.Li type billingAttribution struct { Name string `json:"name"` PreviousCost float64 `json:"previous_cost"` CurrentCost float64 `json:"current_cost"` Delta float64 `json:"delta"` Contribution float64 `json:"contribution"` } // GetBillingCostAnalysis 返回多云费用堆叠趋势及同期归因。 // Logan.Li func (h *BillingHandler) GetBillingCostAnalysis(c *gin.Context) { now := time.Now() endMonth := c.DefaultQuery("end_month", now.Format("2006-01")) startMonth := c.DefaultQuery("start_month", now.AddDate(0, -5, 0).Format("2006-01")) currency := strings.ToUpper(c.DefaultQuery("currency", h.billing.BaseCurrency)) costExpression, costArgs, currencyOK := h.currencyExpression(currency) if !currencyOK { c.JSON(http.StatusBadRequest, model.Response{Code: 400, Message: "不支持的报告币种"}) return } dimension := c.DefaultQuery("dimension", "service") dimensionColumns := map[string]string{ "service": "service", "provider": "provider", "account": "account_name", "region": "region", } column, ok := dimensionColumns[dimension] if !ok { c.JSON(http.StatusBadRequest, model.Response{Code: 400, Message: "不支持的分析维度"}) return } start, startErr := time.ParseInLocation("2006-01", startMonth, now.Location()) end, endErr := time.ParseInLocation("2006-01", endMonth, now.Location()) if startErr != nil || endErr != nil || start.After(end) || end.After(start.AddDate(0, 23, 0)) { c.JSON(http.StatusBadRequest, model.Response{Code: 400, Message: "月份范围无效,最多查询 24 个月"}) return } type aggregateRow struct { Month string Name string Cost float64 } var rows []aggregateRow queryEnd := end.AddDate(0, 1, 0) query := h.db.Model(&model.CloudBilling{}). Select("SUBSTR(billing_date, 1, 7) AS month, "+column+" AS name, COALESCE(SUM("+costExpression+"), 0) AS cost", costArgs...). Where("billing_date >= ? AND billing_date < ?", start.Format("2006-01-02"), queryEnd.Format("2006-01-02")) if provider := c.Query("provider"); provider != "" { query = query.Where("provider = ?", provider) } if accountID := c.Query("cloud_account_id"); accountID != "" { query = query.Where("cloud_account_id = ?", accountID) } if err := query.Group("SUBSTR(billing_date, 1, 7), " + column).Scan(&rows).Error; err != nil { c.JSON(http.StatusInternalServerError, model.Response{Code: 500, Message: "费用分析查询失败"}) return } monthCosts := map[string]map[string]float64{} categoryTotals := map[string]float64{} for _, row := range rows { name := row.Name if name == "" { name = "未分类" } if monthCosts[row.Month] == nil { monthCosts[row.Month] = map[string]float64{} } monthCosts[row.Month][name] += row.Cost categoryTotals[name] += row.Cost } categories := topBillingCategories(categoryTotals, 8) months := buildBillingAnalysisMonths(start, end, monthCosts, categories) totalCost := 0.0 peakCost := 0.0 peakMonth := "" for _, month := range months { totalCost += month.Total if month.Total > peakCost { peakCost = month.Total peakMonth = month.Month } } averageCost := 0.0 if len(months) > 0 { averageCost = totalCost / float64(len(months)) } previousCost, currentCost := 0.0, 0.0 if len(months) >= 2 { previousCost = months[len(months)-2].Total currentCost = months[len(months)-1].Total } else if len(months) == 1 { currentCost = months[0].Total } momPercent := 0.0 if previousCost != 0 { momPercent = (currentCost - previousCost) / previousCost * 100 } attributions := buildBillingAttributions(monthCosts[end.AddDate(0, -1, 0).Format("2006-01")], monthCosts[endMonth]) var dataThrough string dataThroughQuery := h.db.Model(&model.CloudBilling{}) if provider := c.Query("provider"); provider != "" { dataThroughQuery = dataThroughQuery.Where("provider = ?", provider) } if accountID := c.Query("cloud_account_id"); accountID != "" { dataThroughQuery = dataThroughQuery.Where("cloud_account_id = ?", accountID) } dataThroughQuery.Select("COALESCE(MAX(billing_date), '')").Scan(&dataThrough) incompletePeriod := endMonth == now.Format("2006-01") && dataThrough < end.AddDate(0, 1, -1).Format("2006-01-02") metadata := h.reportCurrencyMetadata(currency) c.JSON(http.StatusOK, model.Response{Code: 200, Message: "success", Data: gin.H{ "start_month": startMonth, "end_month": endMonth, "currency": currency, "dimension": dimension, "exchange_rates": metadata["exchange_rates"], "rate_source": metadata["rate_source"], "data_through": dataThrough, "incomplete_period": incompletePeriod, "categories": categories, "months": months, "summary": gin.H{"total_cost": roundBilling(totalCost), "average_cost": roundBilling(averageCost), "peak_cost": roundBilling(peakCost), "peak_month": peakMonth, "mom_percent": roundBilling(momPercent)}, "attributions": attributions, }}) } // topBillingCategories 按总费用选择 Top N 分类,其余合并为“其他”。 // Logan.Li func topBillingCategories(totals map[string]float64, limit int) []string { names := make([]string, 0, len(totals)) for name := range totals { names = append(names, name) } sort.Slice(names, func(i, j int) bool { return totals[names[i]] > totals[names[j]] }) if len(names) > limit { return append(names[:limit], "其他") } return names } // buildBillingAnalysisMonths 补齐空月份并将非 Top 分类合并。 // Logan.Li func buildBillingAnalysisMonths(start, end time.Time, costs map[string]map[string]float64, categories []string) []billingAnalysisMonth { top := map[string]bool{} for _, name := range categories { if name != "其他" { top[name] = true } } months := make([]billingAnalysisMonth, 0) for cursor := start; !cursor.After(end); cursor = cursor.AddDate(0, 1, 0) { month := cursor.Format("2006-01") grouped := map[string]float64{} total := 0.0 for name, cost := range costs[month] { key := name if !top[name] { key = "其他" } grouped[key] += cost total += cost } items := make([]billingAnalysisItem, 0, len(categories)) for _, name := range categories { cost := grouped[name] percent := 0.0 if total != 0 { percent = cost / total * 100 } items = append(items, billingAnalysisItem{Name: name, Cost: roundBilling(cost), Percent: roundBilling(percent)}) } months = append(months, billingAnalysisMonth{Month: month, Total: roundBilling(total), Items: items}) } return months } // buildBillingAttributions 计算最近两个月各分类对总变化的贡献度。 // Logan.Li func buildBillingAttributions(previous, current map[string]float64) []billingAttribution { names := map[string]bool{} previousTotal, currentTotal := 0.0, 0.0 for name, cost := range previous { names[name] = true previousTotal += cost } for name, cost := range current { names[name] = true currentTotal += cost } totalDelta := currentTotal - previousTotal items := make([]billingAttribution, 0, len(names)) for rawName := range names { name := rawName if name == "" { name = "未分类" } delta := current[rawName] - previous[rawName] if math.Abs(delta) < 0.01 { continue } contribution := 0.0 if math.Abs(totalDelta) >= 0.01 { contribution = delta / totalDelta * 100 } items = append(items, billingAttribution{Name: name, PreviousCost: roundBilling(previous[rawName]), CurrentCost: roundBilling(current[rawName]), Delta: roundBilling(delta), Contribution: roundBilling(contribution)}) } sort.Slice(items, func(i, j int) bool { return math.Abs(items[i].Delta) > math.Abs(items[j].Delta) }) if len(items) > 10 { items = items[:10] } return items } // roundBilling 统一费用和百分比精度。 // Logan.Li func roundBilling(value float64) float64 { return math.Round(value*100) / 100 }