feat: 补全多云资产与账单同步

- 接入 AWS、华为云和 Azure 资产及账单 API

- 按原始币种隔离统计并修正账号服务器数

- 修复侧栏菜单展开后的滚动行为
This commit is contained in:
Logan.Li committed 2026-07-29 06:15:00 +08:00
1 parent 65e2f09b97
commit 132fe9ba97
25 files changed
+1094 -141

No files matched your search

+13 -8
View File
@@ -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(&current)
} 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)
}
+41 -1
View File
@@ -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: "删除成功",
})
}
+27 -22
View File
@@ -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,