Files
Logan.Li fd60361d93 chore: 统一本地服务器与远程仓库源码
- 合并 main 与 master 历史并以服务器结构为基线
- 保留迁移、数据保留、AWS 账单与部署安全能力
- 建立 SDD 规格和 CI 验证闭环
2026-08-05 20:03:25 +08:00

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
}