feat: 补全多云资产与账单同步
- 接入 AWS、华为云和 Azure 资产及账单 API - 按原始币种隔离统计并修正账号服务器数 - 修复侧栏菜单展开后的滚动行为
This commit is contained in:
1 parent
65e2f09b97
commit
132fe9ba97
25 files changed
+1094
-141
No files matched your search
@@ -37,6 +37,9 @@ func (h *BillingHandler) ListBillings(c *gin.Context) {
|
||||
if accountID := c.Query("cloud_account_id"); accountID != "" {
|
||||
query = query.Where("cloud_account_id = ?", accountID)
|
||||
}
|
||||
if currency := c.Query("currency"); currency != "" {
|
||||
query = query.Where("currency = ?", currency)
|
||||
}
|
||||
if startDate := c.Query("start_date"); startDate != "" {
|
||||
query = query.Where("billing_date >= ?", startDate)
|
||||
}
|
||||
@@ -167,27 +170,29 @@ func (h *BillingHandler) GetBillingStats(c *gin.Context) {
|
||||
yesterday := now.AddDate(0, 0, -1).Format("2006-01-02")
|
||||
monthStart := now.Format("2006-01") + "-01"
|
||||
yearStart := fmt.Sprintf("%d-01-01", now.Year())
|
||||
currency := c.DefaultQuery("currency", "CNY")
|
||||
|
||||
stats := model.BillingStats{
|
||||
Currency: currency,
|
||||
MonthTrend: []model.DailyCost{},
|
||||
ProviderDist: []model.ProviderCost{},
|
||||
Alerts: []model.BillingAlert{},
|
||||
}
|
||||
|
||||
// 今日成本
|
||||
h.db.Model(&model.CloudBilling{}).Where("billing_date = ?", today).
|
||||
h.db.Model(&model.CloudBilling{}).Where("billing_date = ? AND currency = ?", today, currency).
|
||||
Select("COALESCE(SUM(cost), 0)").Scan(&stats.TodayCost)
|
||||
|
||||
// 昨日成本
|
||||
h.db.Model(&model.CloudBilling{}).Where("billing_date = ?", yesterday).
|
||||
h.db.Model(&model.CloudBilling{}).Where("billing_date = ? AND currency = ?", yesterday, currency).
|
||||
Select("COALESCE(SUM(cost), 0)").Scan(&stats.YesterdayCost)
|
||||
|
||||
// 本月成本
|
||||
h.db.Model(&model.CloudBilling{}).Where("billing_date >= ?", monthStart).
|
||||
h.db.Model(&model.CloudBilling{}).Where("billing_date >= ? AND currency = ?", monthStart, currency).
|
||||
Select("COALESCE(SUM(cost), 0)").Scan(&stats.MonthCost)
|
||||
|
||||
// 本年成本
|
||||
h.db.Model(&model.CloudBilling{}).Where("billing_date >= ?", yearStart).
|
||||
h.db.Model(&model.CloudBilling{}).Where("billing_date >= ? AND currency = ?", yearStart, currency).
|
||||
Select("COALESCE(SUM(cost), 0)").Scan(&stats.YearCost)
|
||||
|
||||
// 近30天趋势
|
||||
@@ -198,7 +203,7 @@ func (h *BillingHandler) GetBillingStats(c *gin.Context) {
|
||||
var trends []dayCost
|
||||
h.db.Model(&model.CloudBilling{}).
|
||||
Select("billing_date, SUM(cost) as total").
|
||||
Where("billing_date >= ?", now.AddDate(0, 0, -29).Format("2006-01-02")).
|
||||
Where("billing_date >= ? AND currency = ?", now.AddDate(0, 0, -29).Format("2006-01-02"), currency).
|
||||
Group("billing_date").
|
||||
Order("billing_date ASC").
|
||||
Scan(&trends)
|
||||
@@ -215,7 +220,7 @@ func (h *BillingHandler) GetBillingStats(c *gin.Context) {
|
||||
var provCosts []provCost
|
||||
h.db.Model(&model.CloudBilling{}).
|
||||
Select("provider, SUM(cost) as total").
|
||||
Where("billing_date >= ?", monthStart).
|
||||
Where("billing_date >= ? AND currency = ?", monthStart, currency).
|
||||
Group("provider").
|
||||
Scan(&provCosts)
|
||||
|
||||
@@ -258,7 +263,7 @@ func (h *BillingHandler) checkBillingAlerts(stats *model.BillingStats, now time.
|
||||
|
||||
if rule.Metric == "daily" {
|
||||
// 查今日该厂商/账号的成本
|
||||
q := h.db.Model(&model.CloudBilling{}).Where("billing_date = ?", now.Format("2006-01-02"))
|
||||
q := h.db.Model(&model.CloudBilling{}).Where("billing_date = ? AND currency = ?", now.Format("2006-01-02"), stats.Currency)
|
||||
if rule.Provider != "" {
|
||||
q = q.Where("provider = ?", rule.Provider)
|
||||
}
|
||||
@@ -268,7 +273,7 @@ func (h *BillingHandler) checkBillingAlerts(stats *model.BillingStats, now time.
|
||||
q.Select("COALESCE(SUM(cost), 0)").Scan(¤t)
|
||||
} else {
|
||||
// monthly - 本月累计
|
||||
q := h.db.Model(&model.CloudBilling{}).Where("billing_date >= ?", now.Format("2006-01")+"-01")
|
||||
q := h.db.Model(&model.CloudBilling{}).Where("billing_date >= ? AND currency = ?", now.Format("2006-01")+"-01", stats.Currency)
|
||||
if rule.Provider != "" {
|
||||
q = q.Where("provider = ?", rule.Provider)
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package controller
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
@@ -32,6 +33,24 @@ func NewCloudAccountHandler(db *gorm.DB) *CloudAccountHandler {
|
||||
func (h *CloudAccountHandler) ListCloudAccounts(c *gin.Context) {
|
||||
var accounts []model.CloudAccount
|
||||
h.db.Find(&accounts)
|
||||
// 服务器数来自真实云账号关联,避免重复导入后被“本次新增数”覆盖。 // Logan.Li
|
||||
type accountServerCount struct {
|
||||
CloudAccountID uint
|
||||
Total int
|
||||
}
|
||||
var serverCounts []accountServerCount
|
||||
h.db.Model(&model.Server{}).
|
||||
Select("cloud_account_id, COUNT(*) AS total").
|
||||
Where("cloud_account_id IS NOT NULL").Group("cloud_account_id").Scan(&serverCounts)
|
||||
countByAccount := make(map[uint]int, len(serverCounts))
|
||||
for _, item := range serverCounts {
|
||||
countByAccount[item.CloudAccountID] = item.Total
|
||||
}
|
||||
for index := range accounts {
|
||||
if total, ok := countByAccount[accounts[index].ID]; ok {
|
||||
accounts[index].ServerCount = total
|
||||
}
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, model.Response{
|
||||
Code: 200,
|
||||
@@ -80,6 +99,10 @@ func (h *CloudAccountHandler) CreateCloudAccount(c *gin.Context) {
|
||||
})
|
||||
return
|
||||
}
|
||||
if err := validateCloudAccountCredentials(req.Provider, req.AccessKey, req.SecretKey); err != nil {
|
||||
c.JSON(http.StatusBadRequest, model.Response{Code: 400, Message: err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
status := strings.TrimSpace(req.Status)
|
||||
if status == "" {
|
||||
@@ -158,6 +181,10 @@ func (h *CloudAccountHandler) UpdateCloudAccount(c *gin.Context) {
|
||||
account.Status = strings.TrimSpace(req.Status)
|
||||
}
|
||||
account.MonthlyCost = req.MonthlyCost
|
||||
if err := validateCloudAccountCredentials(account.Provider, account.AccessKey, account.SecretKey); err != nil {
|
||||
c.JSON(http.StatusBadRequest, model.Response{Code: 400, Message: err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.db.Save(&account).Error; err != nil {
|
||||
c.JSON(http.StatusInternalServerError, model.Response{
|
||||
@@ -174,6 +201,20 @@ func (h *CloudAccountHandler) UpdateCloudAccount(c *gin.Context) {
|
||||
})
|
||||
}
|
||||
|
||||
// validateCloudAccountCredentials 校验各云厂商调用 API 所需的凭据格式。 // Logan.Li
|
||||
func validateCloudAccountCredentials(provider, accessKey, secretKey string) error {
|
||||
if strings.TrimSpace(accessKey) == "" || strings.TrimSpace(secretKey) == "" {
|
||||
return fmt.Errorf("AccessKey 和 SecretKey 不能为空")
|
||||
}
|
||||
if provider == "azure" {
|
||||
parts := strings.Split(accessKey, ":")
|
||||
if len(parts) != 3 || strings.TrimSpace(parts[0]) == "" || strings.TrimSpace(parts[1]) == "" || strings.TrimSpace(parts[2]) == "" {
|
||||
return fmt.Errorf("Azure AccessKey 格式应为 租户ID:订阅ID:客户端ID")
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// DeleteCloudAccount 删除云账号
|
||||
// @Summary 删除云账号
|
||||
// @Description 删除指定云账号
|
||||
@@ -199,4 +240,3 @@ func (h *CloudAccountHandler) DeleteCloudAccount(c *gin.Context) {
|
||||
Message: "删除成功",
|
||||
})
|
||||
}
|
||||
|
||||
@@ -227,10 +227,12 @@ func (h *CloudSyncHandler) ImportInstances(c *gin.Context) {
|
||||
ip = inst.InstanceID
|
||||
}
|
||||
|
||||
// 检查是否已存在(按 IP 或 hostname)
|
||||
var count int64
|
||||
h.db.Model(&model.Server{}).Where("ip = ? OR hostname = ?", ip, inst.Hostname).Count(&count)
|
||||
if count > 0 {
|
||||
// 重复资产补齐来源账号关联,保证服务器数统计准确。 // Logan.Li
|
||||
var existing model.Server
|
||||
if err := h.db.Where("ip = ? OR hostname = ?", ip, inst.Hostname).Take(&existing).Error; err == nil {
|
||||
if existing.CloudAccountID == nil {
|
||||
h.db.Model(&existing).Update("cloud_account_id", account.ID)
|
||||
}
|
||||
skipped++
|
||||
continue
|
||||
}
|
||||
@@ -275,22 +277,23 @@ func (h *CloudSyncHandler) ImportInstances(c *gin.Context) {
|
||||
// 注意:servers 表的 cloud_id 外键引用的是 clouds 表(非 cloud_accounts),
|
||||
// region_id 外键引用 regions 表。未找到关联时设为 NULL 避免外键约束失败。
|
||||
server := model.Server{
|
||||
Hostname: hostname,
|
||||
IP: ipAddr,
|
||||
PublicIP: inst.PublicIP,
|
||||
Port: defaultPort,
|
||||
Username: username,
|
||||
AuthType: "password",
|
||||
OSType: osType,
|
||||
Protocol: protocol,
|
||||
RDPPort: 3389,
|
||||
OS: osName,
|
||||
CPU: inst.CPU,
|
||||
Memory: int64(inst.Memory),
|
||||
Status: status,
|
||||
RegionID: regionID, // nil = NULL
|
||||
Description: "从 " + account.Provider + " 导入 (" + inst.InstanceID + ")",
|
||||
AgentStatus: "offline",
|
||||
Hostname: hostname,
|
||||
IP: ipAddr,
|
||||
PublicIP: inst.PublicIP,
|
||||
Port: defaultPort,
|
||||
Username: username,
|
||||
AuthType: "password",
|
||||
OSType: osType,
|
||||
Protocol: protocol,
|
||||
RDPPort: 3389,
|
||||
OS: osName,
|
||||
CPU: inst.CPU,
|
||||
Memory: int64(inst.Memory),
|
||||
Status: status,
|
||||
RegionID: regionID, // nil = NULL
|
||||
CloudAccountID: &account.ID,
|
||||
Description: "从 " + account.Provider + " 导入 (" + inst.InstanceID + ")",
|
||||
AgentStatus: "offline",
|
||||
}
|
||||
if protocol == "rdp" && hasRDPCred {
|
||||
applyCredential(rdpCred, rdpPlain, &server)
|
||||
@@ -305,8 +308,10 @@ func (h *CloudSyncHandler) ImportInstances(c *gin.Context) {
|
||||
}
|
||||
}
|
||||
|
||||
// 更新账号的 server_count
|
||||
h.db.Model(&account).UpdateColumn("server_count", imported)
|
||||
// 保存该账号当前已关联的资产总数,而不是本次新增数。 // Logan.Li
|
||||
var serverCount int64
|
||||
h.db.Model(&model.Server{}).Where("cloud_account_id = ?", account.ID).Count(&serverCount)
|
||||
h.db.Model(&account).UpdateColumn("server_count", serverCount)
|
||||
|
||||
c.JSON(http.StatusOK, model.Response{
|
||||
Code: 200,
|
||||
|
||||
Reference in new issue
Block a user