package controller import ( "fmt" "net/http" "sort" "strings" "sync" "time" "ezops/config" "ezops/middleware" "ezops/model" "github.com/gin-gonic/gin" "gorm.io/gorm" ) // BillingHandler 云账单处理器 type BillingHandler struct { db *gorm.DB billing *config.BillingConfig rateMu sync.RWMutex } func NewBillingHandler(db *gorm.DB, configs ...*config.BillingConfig) *BillingHandler { billingConfig := &config.BillingConfig{BaseCurrency: "CNY", ExchangeRates: map[string]float64{"CNY": 1, "USD": 7.2}} if len(configs) > 0 && configs[0] != nil { billingConfig = &config.BillingConfig{BaseCurrency: strings.ToUpper(configs[0].BaseCurrency), ExchangeRates: map[string]float64{}} for currency, rate := range configs[0].ExchangeRates { billingConfig.ExchangeRates[strings.ToUpper(currency)] = rate } } if billingConfig.BaseCurrency == "" { billingConfig.BaseCurrency = "CNY" } if len(billingConfig.ExchangeRates) == 0 { billingConfig.ExchangeRates = map[string]float64{"CNY": 1, "USD": 7.2} } // 已完成迁移的环境优先使用页面保存的汇率。 // Logan.Li if db != nil && db.Migrator().HasTable(&model.BillingSettings{}) { var settings model.BillingSettings if db.Take(&settings).Error == nil && settings.USDCNYRate > 0 { billingConfig.ExchangeRates["USD"] = settings.USDCNYRate } } return &BillingHandler{db: db, billing: billingConfig} } // GetBillingSettings 获取云账单换算设置。 // Logan.Li func (h *BillingHandler) GetBillingSettings(c *gin.Context) { h.rateMu.RLock() rate := h.billing.ExchangeRates["USD"] h.rateMu.RUnlock() var settings model.BillingSettings if h.db.Migrator().HasTable(&model.BillingSettings{}) && h.db.Take(&settings).Error == nil && settings.USDCNYRate > 0 { rate = settings.USDCNYRate } c.JSON(http.StatusOK, model.Response{Code: 200, Message: "success", Data: gin.H{ "base_currency": "CNY", "usd_cny_rate": rate, "updated_at": settings.UpdatedAt, "updated_by": settings.UpdatedBy, }}) } // SaveBillingSettings 保存 USD/CNY 汇率并立即应用。 // Logan.Li func (h *BillingHandler) SaveBillingSettings(c *gin.Context) { var req struct { USDCNYRate float64 `json:"usd_cny_rate" binding:"required"` } if err := c.ShouldBindJSON(&req); err != nil || req.USDCNYRate < 0.1 || req.USDCNYRate > 100 { c.JSON(http.StatusBadRequest, model.Response{Code: 400, Message: "USD/CNY 汇率必须在 0.1 到 100 之间"}) return } if !h.db.Migrator().HasTable(&model.BillingSettings{}) { c.JSON(http.StatusInternalServerError, model.Response{Code: 500, Message: "云账单设置表尚未迁移"}) return } var settings model.BillingSettings err := h.db.Take(&settings).Error settings.USDCNYRate = mathRound(req.USDCNYRate, 6) settings.UpdatedBy = middleware.GetUsername(c) if err == gorm.ErrRecordNotFound { err = h.db.Create(&settings).Error } else if err == nil { err = h.db.Save(&settings).Error } if err != nil { c.JSON(http.StatusInternalServerError, model.Response{Code: 500, Message: "保存汇率失败"}) return } h.rateMu.Lock() h.billing.ExchangeRates["CNY"] = 1 h.billing.ExchangeRates["USD"] = settings.USDCNYRate h.rateMu.Unlock() c.JSON(http.StatusOK, model.Response{Code: 200, Message: "汇率已保存", Data: gin.H{ "base_currency": "CNY", "usd_cny_rate": settings.USDCNYRate, "updated_at": settings.UpdatedAt, "updated_by": settings.UpdatedBy, }}) } // currencyExpression 返回将原始费用折算为目标报告币种的 SQL 表达式及参数。 // Logan.Li func (h *BillingHandler) currencyExpression(target string) (string, []interface{}, bool) { h.rateMu.RLock() defer h.rateMu.RUnlock() target = strings.ToUpper(target) targetRate := h.billing.ExchangeRates[target] if targetRate <= 0 { return "", nil, false } parts := make([]string, 0, len(h.billing.ExchangeRates)) args := make([]interface{}, 0, len(h.billing.ExchangeRates)) for currency, rate := range h.billing.ExchangeRates { if rate <= 0 { continue } parts = append(parts, "WHEN '"+strings.ToUpper(currency)+"' THEN cost * ?") args = append(args, rate/targetRate) } return "CASE UPPER(currency) " + strings.Join(parts, " ") + " ELSE 0 END", args, true } // reportCurrencyMetadata 返回前端展示和审计换算口径所需的元数据。 // Logan.Li func (h *BillingHandler) reportCurrencyMetadata(target string) gin.H { h.rateMu.RLock() defer h.rateMu.RUnlock() rates := map[string]float64{} targetRate := h.billing.ExchangeRates[strings.ToUpper(target)] for currency, rate := range h.billing.ExchangeRates { if rate > 0 && targetRate > 0 { rates[strings.ToUpper(currency)] = mathRound(rate/targetRate, 6) } } return gin.H{"currency": strings.ToUpper(target), "exchange_rates": rates, "rate_source": "configured"} } // mathRound 按指定小数位返回稳定的汇率展示值。 // Logan.Li func mathRound(value float64, precision int) float64 { factor := 1.0 for i := 0; i < precision; i++ { factor *= 10 } return float64(int64(value*factor+0.5)) / factor } // ListBillings 获取账单列表 func (h *BillingHandler) ListBillings(c *gin.Context) { var billings []model.CloudBilling query := h.db.Model(&model.CloudBilling{}) // 筛选 if provider := c.Query("provider"); provider != "" { query = query.Where("provider = ?", provider) } if accountName := c.Query("account_name"); accountName != "" { query = query.Where("account_name LIKE ?", "%"+accountName+"%") } if accountID := c.Query("cloud_account_id"); accountID != "" { query = query.Where("cloud_account_id = ?", accountID) } if startDate := c.Query("start_date"); startDate != "" { query = query.Where("billing_date >= ?", startDate) } if endDate := c.Query("end_date"); endDate != "" { query = query.Where("billing_date <= ?", endDate) } // 排序 query = query.Order("billing_date DESC, id DESC") // 分页 page := 1 pageSize := 50 if c.Query("page") != "" { fmt.Sscanf(c.Query("page"), "%d", &page) } if c.Query("page_size") != "" { fmt.Sscanf(c.Query("page_size"), "%d", &pageSize) } if page < 1 { page = 1 } if pageSize < 1 || pageSize > 500 { pageSize = 50 } var total int64 query.Count(&total) query.Offset((page - 1) * pageSize).Limit(pageSize).Find(&billings) c.JSON(http.StatusOK, model.Response{ Code: 200, Message: "success", Data: model.ListResponse{ List: billings, Total: total, }, }) } // CreateBilling 创建账单记录 func (h *BillingHandler) CreateBilling(c *gin.Context) { var req model.CreateBillingRequest if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, model.Response{Code: 400, Message: err.Error()}) return } billing := model.CloudBilling{ CloudAccountID: req.CloudAccountID, AccountName: req.AccountName, Provider: req.Provider, BillingDate: req.BillingDate, Cost: req.Cost, Currency: req.Currency, Service: req.Service, Region: req.Region, Description: req.Description, } if billing.Currency == "" { billing.Currency = "CNY" } billing.Currency = strings.ToUpper(billing.Currency) if err := h.db.Create(&billing).Error; err != nil { c.JSON(http.StatusInternalServerError, model.Response{Code: 500, Message: "创建失败: " + err.Error()}) return } c.JSON(http.StatusOK, model.Response{ Code: 200, Message: "创建成功", Data: billing, }) } // BatchCreateBillings 批量创建账单 func (h *BillingHandler) BatchCreateBillings(c *gin.Context) { var req struct { Records []model.CreateBillingRequest `json:"records" binding:"required"` } if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, model.Response{Code: 400, Message: err.Error()}) return } created := 0 for _, r := range req.Records { billing := model.CloudBilling{ CloudAccountID: r.CloudAccountID, AccountName: r.AccountName, Provider: r.Provider, BillingDate: r.BillingDate, Cost: r.Cost, Currency: r.Currency, Service: r.Service, Region: r.Region, Description: r.Description, } if billing.Currency == "" { billing.Currency = "CNY" } billing.Currency = strings.ToUpper(billing.Currency) if err := h.db.Create(&billing).Error; err == nil { created++ } } c.JSON(http.StatusOK, model.Response{ Code: 200, Message: fmt.Sprintf("成功导入 %d 条记录", created), Data: gin.H{"created": created, "total": len(req.Records)}, }) } // DeleteBilling 删除账单记录 func (h *BillingHandler) DeleteBilling(c *gin.Context) { id := c.Param("id") if err := h.db.Delete(&model.CloudBilling{}, id).Error; err != nil { c.JSON(http.StatusInternalServerError, model.Response{Code: 500, Message: "删除失败"}) return } c.JSON(http.StatusOK, model.Response{Code: 200, Message: "删除成功"}) } // GetBillingStats 获取账单统计 func (h *BillingHandler) GetBillingStats(c *gin.Context) { now := time.Now() reportCurrency := strings.ToUpper(c.DefaultQuery("currency", h.billing.BaseCurrency)) costExpression, costArgs, ok := h.currencyExpression(reportCurrency) if !ok { c.JSON(http.StatusBadRequest, model.Response{Code: 400, Message: "不支持的报告币种"}) return } today := now.Format("2006-01-02") yesterday := now.AddDate(0, 0, -1).Format("2006-01-02") monthStart := now.Format("2006-01") + "-01" yearStart := fmt.Sprintf("%d-01-01", now.Year()) stats := model.BillingStats{ MonthTrend: []model.DailyCost{}, ProviderDist: []model.ProviderCost{}, ProviderTrend: []model.ProviderDailyCost{}, TopServices: []model.ServiceCost{}, Alerts: []model.BillingAlert{}, Currency: reportCurrency, ExchangeRates: h.reportCurrencyMetadata(reportCurrency)["exchange_rates"].(map[string]float64), RateSource: "configured", } // 所有统计统一按报告币种折算,避免不同原币种直接相加。 // Logan.Li h.db.Model(&model.CloudBilling{}).Where("billing_date = ?", today). Select("COALESCE(SUM("+costExpression+"), 0)", costArgs...).Scan(&stats.TodayCost) // 昨日成本 h.db.Model(&model.CloudBilling{}).Where("billing_date = ?", yesterday). Select("COALESCE(SUM("+costExpression+"), 0)", costArgs...).Scan(&stats.YesterdayCost) // 本月成本 h.db.Model(&model.CloudBilling{}).Where("billing_date >= ?", monthStart). Select("COALESCE(SUM("+costExpression+"), 0)", costArgs...).Scan(&stats.MonthCost) // 本年成本 h.db.Model(&model.CloudBilling{}).Where("billing_date >= ?", yearStart). Select("COALESCE(SUM("+costExpression+"), 0)", costArgs...).Scan(&stats.YearCost) // 本月日趋势与厂商日趋势来自同一聚合口径,供成本预测直接复用。 // Logan.Li type dayCost struct { BillingDate string Total float64 } var trends []dayCost h.db.Model(&model.CloudBilling{}). Select("billing_date, SUM("+costExpression+") as total", costArgs...). Where("billing_date >= ?", monthStart). Group("billing_date"). Order("billing_date ASC"). Scan(&trends) for _, t := range trends { stats.MonthTrend = append(stats.MonthTrend, model.DailyCost{Date: t.BillingDate, Cost: t.Total}) if t.BillingDate > stats.DataThrough { stats.DataThrough = t.BillingDate } } // 本月厂商成本分布 type provCost struct { Provider string Total float64 } var provCosts []provCost h.db.Model(&model.CloudBilling{}). Select("provider, SUM("+costExpression+") as total", costArgs...). Where("billing_date >= ?", monthStart). Group("provider"). Scan(&provCosts) var totalProviderCost float64 for _, p := range provCosts { totalProviderCost += p.Total } for _, p := range provCosts { percent := 0.0 if totalProviderCost > 0 { percent = p.Total / totalProviderCost * 100 } stats.ProviderDist = append(stats.ProviderDist, model.ProviderCost{ Provider: p.Provider, Cost: p.Total, Percent: percent, }) } sort.Slice(stats.ProviderDist, func(i, j int) bool { return stats.ProviderDist[i].Cost > stats.ProviderDist[j].Cost }) type providerDayCost struct { Provider string BillingDate string Total float64 } var providerTrends []providerDayCost h.db.Model(&model.CloudBilling{}). Select("provider, billing_date, SUM("+costExpression+") as total", costArgs...). Where("billing_date >= ?", monthStart). Group("provider, billing_date").Order("billing_date ASC").Scan(&providerTrends) for _, item := range providerTrends { stats.ProviderTrend = append(stats.ProviderTrend, model.ProviderDailyCost{Provider: item.Provider, Date: item.BillingDate, Cost: item.Total}) } type serviceCost struct { Service string Total float64 } var services []serviceCost h.db.Model(&model.CloudBilling{}). Select("service, SUM("+costExpression+") as total", costArgs...). Where("billing_date >= ?", monthStart). Group("service").Order("total DESC").Limit(5).Scan(&services) for _, item := range services { name := item.Service if name == "" { name = "未分类" } stats.TopServices = append(stats.TopServices, model.ServiceCost{Service: name, Cost: item.Total}) } // 检查预警规则 h.checkBillingAlerts(&stats, now, costExpression, costArgs) c.JSON(http.StatusOK, model.Response{ Code: 200, Message: "success", Data: stats, }) } // checkBillingAlerts 检查预警规则是否触发 func (h *BillingHandler) checkBillingAlerts(stats *model.BillingStats, now time.Time, costExpression string, costArgs []interface{}) { var rules []model.BillingAlertRule h.db.Where("enabled = ?", true).Find(&rules) for _, rule := range rules { var current float64 if rule.Metric == "daily" { // 查今日该厂商/账号的成本 q := h.db.Model(&model.CloudBilling{}).Where("billing_date = ?", now.Format("2006-01-02")) if rule.Provider != "" { q = q.Where("provider = ?", rule.Provider) } if rule.CloudAccountID != nil { q = q.Where("cloud_account_id = ?", *rule.CloudAccountID) } q.Select("COALESCE(SUM("+costExpression+"), 0)", costArgs...).Scan(¤t) } else { // monthly - 本月累计 q := h.db.Model(&model.CloudBilling{}).Where("billing_date >= ?", now.Format("2006-01")+"-01") if rule.Provider != "" { q = q.Where("provider = ?", rule.Provider) } if rule.CloudAccountID != nil { q = q.Where("cloud_account_id = ?", *rule.CloudAccountID) } q.Select("COALESCE(SUM("+costExpression+"), 0)", costArgs...).Scan(¤t) } // 检查阈值 triggered := false switch rule.Operator { case ">": triggered = current > rule.Threshold case ">=": triggered = current >= rule.Threshold case "<": triggered = current < rule.Threshold case "<=": triggered = current <= rule.Threshold } if triggered { providerLabel := rule.Provider if providerLabel == "" { providerLabel = "全部" } stats.Alerts = append(stats.Alerts, model.BillingAlert{ RuleID: rule.ID, RuleName: rule.Name, Provider: providerLabel, Current: current, Threshold: rule.Threshold, Metric: rule.Metric, Operator: rule.Operator, }) } } }