237 lines
8.3 KiB
Go
237 lines
8.3 KiB
Go
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
|
|
}
|