From 132fe9ba9722a7333f67b81d382215b64d02172b Mon Sep 17 00:00:00 2001 From: "Logan.Li" Date: Wed, 29 Jul 2026 06:15:00 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E8=A1=A5=E5=85=A8=E5=A4=9A=E4=BA=91?= =?UTF-8?q?=E8=B5=84=E4=BA=A7=E4=B8=8E=E8=B4=A6=E5=8D=95=E5=90=8C=E6=AD=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 接入 AWS、华为云和 Azure 资产及账单 API - 按原始币种隔离统计并修正账号服务器数 - 修复侧栏菜单展开后的滚动行为 --- backend/cloud/aws.go | 162 ++++++++++++++++ backend/cloud/aws_billing.go | 147 +++++++++++++++ backend/cloud/azure.go | 177 ++++++++++++++++++ backend/cloud/azure_billing.go | 105 +++++++++++ backend/cloud/billing.go | 8 +- backend/cloud/huawei.go | 148 +++++++++++++++ backend/cloud/huawei_billing.go | 98 ++++++++++ backend/cloud/provider.go | 10 +- backend/cloud/tencent_test.go | 9 + backend/controller/billing.go | 21 ++- backend/controller/cloud_accounts.go | 42 ++++- backend/controller/cloud_sync.go | 49 ++--- backend/model/billing.go | 84 ++++----- backend/model/server.go | 94 +++++----- backend/pkg/billingsync/sync.go | 14 +- frontend/src/api/cloud/billing.ts | 6 +- .../aiops/AIPredictiveAnalytics.tsx | 2 +- .../cloud/CloudAccountDetailSheet.tsx | 8 +- .../components/cloud/CloudBillingHelpers.tsx | 19 +- .../cloud/cloud-account/AccountCard.tsx | 4 +- .../cloud/cloud-account/AccountFormDialog.tsx | 12 +- .../cloud/cloud-billing/RecordsTab.tsx | 4 +- frontend/src/components/layout/Sidebar.tsx | 2 +- frontend/src/locales/en-US.json | 5 + frontend/src/locales/zh-CN.json | 5 + 25 files changed, 1094 insertions(+), 141 deletions(-) create mode 100644 backend/cloud/aws.go create mode 100644 backend/cloud/aws_billing.go create mode 100644 backend/cloud/azure.go create mode 100644 backend/cloud/azure_billing.go create mode 100644 backend/cloud/huawei.go create mode 100644 backend/cloud/huawei_billing.go diff --git a/backend/cloud/aws.go b/backend/cloud/aws.go new file mode 100644 index 0000000..9c7be36 --- /dev/null +++ b/backend/cloud/aws.go @@ -0,0 +1,162 @@ +package cloud + +import ( + "crypto/hmac" + "crypto/sha256" + "encoding/hex" + "encoding/xml" + "fmt" + "io" + "net/http" + "net/url" + "sort" + "strings" + "time" +) + +// AWSProvider 通过 EC2 Query API 同步 AWS 区域与实例。 // Logan.Li +type AWSProvider struct { + accessKey string + secretKey string +} + +// NewAWSProvider 创建 AWS Provider。 // Logan.Li +func NewAWSProvider(accessKey, secretKey string) *AWSProvider { + return &AWSProvider{accessKey: accessKey, secretKey: secretKey} +} + +// Name 返回 AWS 厂商标识。 // Logan.Li +func (p *AWSProvider) Name() string { return "aws" } + +// call 使用 AWS Signature V4 调用 EC2 Query API。 // Logan.Li +func (p *AWSProvider) call(region, action string, values url.Values) ([]byte, error) { + if region == "" { + region = "us-east-1" + } + values.Set("Action", action) + values.Set("Version", "2016-11-15") + now := time.Now().UTC() + date := now.Format("20060102") + host := "ec2." + region + ".amazonaws.com" + payload := values.Encode() + canonicalHeaders := "content-type:application/x-www-form-urlencoded; charset=utf-8\nhost:" + host + "\nx-amz-date:" + now.Format("20060102T150405Z") + "\n" + signedHeaders := "content-type;host;x-amz-date" + canonicalRequest := "POST\n/\n\n" + canonicalHeaders + "\n" + signedHeaders + "\n" + sha256Hex(payload) + credentialScope := date + "/" + region + "/ec2/aws4_request" + stringToSign := "AWS4-HMAC-SHA256\n" + now.Format("20060102T150405Z") + "\n" + credentialScope + "\n" + sha256Hex(canonicalRequest) + kDate := hmacSHA256([]byte("AWS4"+p.secretKey), date) + kRegion := hmacSHA256(kDate, region) + kService := hmacSHA256(kRegion, "ec2") + signature := hex.EncodeToString(hmacSHA256(hmacSHA256(kService, "aws4_request"), stringToSign)) + req, err := http.NewRequest(http.MethodPost, "https://"+host+"/", strings.NewReader(payload)) + if err != nil { + return nil, fmt.Errorf("创建 AWS 请求失败: %w", err) + } + req.Header.Set("Content-Type", "application/x-www-form-urlencoded; charset=utf-8") + req.Header.Set("X-Amz-Date", now.Format("20060102T150405Z")) + req.Header.Set("Authorization", "AWS4-HMAC-SHA256 Credential="+p.accessKey+"/"+credentialScope+", SignedHeaders="+signedHeaders+", Signature="+signature) + resp, err := (&http.Client{Timeout: 30 * time.Second}).Do(req) + if err != nil { + return nil, fmt.Errorf("请求 AWS API 失败: %w", err) + } + defer resp.Body.Close() + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("读取 AWS 响应失败: %w", err) + } + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return nil, fmt.Errorf("AWS API 返回 HTTP %d: %s", resp.StatusCode, string(body)) + } + return body, nil +} + +// ListRegions 获取当前凭据可见的 AWS 区域。 // Logan.Li +func (p *AWSProvider) ListRegions() ([]CloudRegion, error) { + body, err := p.call("us-east-1", "DescribeRegions", url.Values{}) + if err != nil { + return nil, err + } + var result struct { + Regions []struct { + Name string `xml:"regionName"` + Endpoint string `xml:"regionEndpoint"` + } `xml:"regionInfo>item"` + } + if err := xml.Unmarshal(body, &result); err != nil { + return nil, fmt.Errorf("解析 AWS 区域响应失败: %w", err) + } + regions := make([]CloudRegion, 0, len(result.Regions)) + for _, region := range result.Regions { + regions = append(regions, CloudRegion{RegionID: region.Name, LocalName: region.Name, Endpoint: "https://" + region.Endpoint, Status: "active"}) + } + sort.Slice(regions, func(i, j int) bool { return regions[i].RegionID < regions[j].RegionID }) + return regions, nil +} + +// ListInstances 获取一个 AWS 区域中的 EC2 实例。 // Logan.Li +func (p *AWSProvider) ListInstances(regionID string) ([]CloudInstance, error) { + body, err := p.call(regionID, "DescribeInstances", url.Values{}) + if err != nil { + return nil, err + } + var result struct { + Reservations []struct { + Instances []struct { + ID string `xml:"instanceId"` + Type string `xml:"instanceType"` + State string `xml:"instanceState>name"` + PrivateIP string `xml:"privateIpAddress"` + PublicIP string `xml:"ipAddress"` + Launch string `xml:"launchTime"` + Placement string `xml:"placement>availabilityZone"` + Platform string `xml:"platform"` + Tags []struct { + Key string `xml:"key"` + Value string `xml:"value"` + } `xml:"tagSet>item"` + } `xml:"instancesSet>item"` + } `xml:"reservationSet>item"` + } + if err := xml.Unmarshal(body, &result); err != nil { + return nil, fmt.Errorf("解析 AWS 实例响应失败: %w", err) + } + instances := []CloudInstance{} + for _, reservation := range result.Reservations { + for _, item := range reservation.Instances { + name := item.ID + for _, tag := range item.Tags { + if tag.Key == "Name" && tag.Value != "" { + name = tag.Value + break + } + } + osType := "linux" + if strings.EqualFold(item.Platform, "windows") { + osType = "windows" + } + instances = append(instances, CloudInstance{InstanceID: item.ID, InstanceName: name, Hostname: name, OSType: osType, Status: awsState(item.State), RegionID: regionID, ZoneID: item.Placement, PrivateIP: item.PrivateIP, PublicIP: item.PublicIP, InstanceType: item.Type, CreationTime: item.Launch}) + } + } + return instances, nil +} + +// awsState 统一 AWS 实例状态,便于复用现有资产导入逻辑。 // Logan.Li +func awsState(state string) string { + if state == "running" { + return "Running" + } + return state +} + +// sha256Hex 计算签名所需的 SHA256 十六进制摘要。 // Logan.Li +func sha256Hex(value string) string { + sum := sha256.Sum256([]byte(value)) + return hex.EncodeToString(sum[:]) +} + +// hmacSHA256 计算签名链中的 HMAC-SHA256。 // Logan.Li +func hmacSHA256(key []byte, value string) []byte { + mac := hmac.New(sha256.New, key) + mac.Write([]byte(value)) + return mac.Sum(nil) +} diff --git a/backend/cloud/aws_billing.go b/backend/cloud/aws_billing.go new file mode 100644 index 0000000..2535083 --- /dev/null +++ b/backend/cloud/aws_billing.go @@ -0,0 +1,147 @@ +package cloud + +import ( + "encoding/hex" + "encoding/json" + "fmt" + "io" + "math" + "net/http" + "sort" + "strconv" + "strings" + "time" +) + +// FetchDailyBill 通过 Cost Explorer 拉取 AWS 单日服务和区域费用。 // Logan.Li +func (p *AWSProvider) FetchDailyBill(billingDate string) ([]CloudBillingRecord, error) { + start, err := time.Parse("2006-01-02", billingDate) + if err != nil { + return nil, fmt.Errorf("账单日期格式应为 YYYY-MM-DD") + } + type aggregationKey struct { + service string + region string + } + aggregation := map[aggregationKey]float64{} + nextToken := "" + currency := "USD" + for { + payload := map[string]interface{}{ + "TimePeriod": map[string]string{"Start": billingDate, "End": start.AddDate(0, 0, 1).Format("2006-01-02")}, + "Granularity": "DAILY", + "Metrics": []string{"UnblendedCost"}, + "GroupBy": []map[string]string{ + {"Type": "DIMENSION", "Key": "SERVICE"}, + {"Type": "DIMENSION", "Key": "REGION"}, + }, + } + if nextToken != "" { + payload["NextPageToken"] = nextToken + } + body, callErr := p.callCostExplorer(payload) + if callErr != nil { + return nil, callErr + } + var response struct { + NextPageToken string `json:"NextPageToken"` + ResultsByTime []struct { + Groups []struct { + Keys []string `json:"Keys"` + Metrics map[string]struct { + Amount string `json:"Amount"` + Unit string `json:"Unit"` + } `json:"Metrics"` + } `json:"Groups"` + } `json:"ResultsByTime"` + } + if err = json.Unmarshal(body, &response); err != nil { + return nil, fmt.Errorf("解析 AWS Cost Explorer 响应失败: %w", err) + } + for _, period := range response.ResultsByTime { + for _, group := range period.Groups { + metric := group.Metrics["UnblendedCost"] + cost, _ := strconv.ParseFloat(metric.Amount, 64) + if cost == 0 { + continue + } + if metric.Unit != "" { + currency = metric.Unit + } + service, region := "其他", "全局" + if len(group.Keys) > 0 && group.Keys[0] != "" { + service = group.Keys[0] + } + if len(group.Keys) > 1 && group.Keys[1] != "" && group.Keys[1] != "NoRegion" { + region = group.Keys[1] + } + aggregation[aggregationKey{service: service, region: region}] += cost + } + } + nextToken = response.NextPageToken + if nextToken == "" { + break + } + } + keys := make([]aggregationKey, 0, len(aggregation)) + for key := range aggregation { + keys = append(keys, key) + } + sort.Slice(keys, func(i, j int) bool { + if keys[i].service == keys[j].service { + return keys[i].region < keys[j].region + } + return keys[i].service < keys[j].service + }) + records := make([]CloudBillingRecord, 0, len(keys)) + for _, key := range keys { + records = append(records, CloudBillingRecord{ + BillingDate: billingDate, Provider: "aws", Cost: math.Round(aggregation[key]*1000000) / 1000000, + Currency: currency, Service: key.service, Region: key.region, + Description: "云 API 自动同步(AWS Cost Explorer)", + }) + } + return records, nil +} + +// callCostExplorer 使用 Signature V4 调用 AWS Cost Explorer JSON API。 // Logan.Li +func (p *AWSProvider) callCostExplorer(payload interface{}) ([]byte, error) { + body, err := json.Marshal(payload) + if err != nil { + return nil, fmt.Errorf("编码 AWS 账单请求失败: %w", err) + } + const host = "ce.us-east-1.amazonaws.com" + const target = "AWSInsightsIndexService.GetCostAndUsage" + now := time.Now().UTC() + amzDate, date := now.Format("20060102T150405Z"), now.Format("20060102") + canonicalHeaders := "content-type:application/x-amz-json-1.1\nhost:" + host + "\nx-amz-date:" + amzDate + "\nx-amz-target:" + target + "\n" + signedHeaders := "content-type;host;x-amz-date;x-amz-target" + canonicalRequest := "POST\n/\n\n" + canonicalHeaders + "\n" + signedHeaders + "\n" + sha256Hex(string(body)) + credentialScope := date + "/us-east-1/ce/aws4_request" + stringToSign := "AWS4-HMAC-SHA256\n" + amzDate + "\n" + credentialScope + "\n" + sha256Hex(canonicalRequest) + kDate := hmacSHA256([]byte("AWS4"+p.secretKey), date) + kRegion := hmacSHA256(kDate, "us-east-1") + kService := hmacSHA256(kRegion, "ce") + signature := hex.EncodeToString(hmacSHA256(hmacSHA256(kService, "aws4_request"), stringToSign)) + req, err := http.NewRequest(http.MethodPost, "https://"+host+"/", strings.NewReader(string(body))) + if err != nil { + return nil, fmt.Errorf("创建 AWS 账单请求失败: %w", err) + } + req.Header.Set("Content-Type", "application/x-amz-json-1.1") + req.Header.Set("X-Amz-Date", amzDate) + req.Header.Set("X-Amz-Target", target) + req.Header.Set("Authorization", "AWS4-HMAC-SHA256 Credential="+p.accessKey+"/"+credentialScope+", SignedHeaders="+signedHeaders+", Signature="+signature) + resp, err := (&http.Client{Timeout: 30 * time.Second}).Do(req) + if err != nil { + return nil, fmt.Errorf("请求 AWS Cost Explorer 失败: %w", err) + } + defer resp.Body.Close() + data, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("读取 AWS Cost Explorer 响应失败: %w", err) + } + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return nil, fmt.Errorf("AWS Cost Explorer 返回 HTTP %d: %s", resp.StatusCode, string(data)) + } + return data, nil +} diff --git a/backend/cloud/azure.go b/backend/cloud/azure.go new file mode 100644 index 0000000..8bc0dd4 --- /dev/null +++ b/backend/cloud/azure.go @@ -0,0 +1,177 @@ +package cloud + +import ( + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "strings" + "time" +) + +// AzureProvider 使用服务主体凭据同步 Azure Resource Manager 虚拟机。 // Logan.Li +type AzureProvider struct{ tenantID, subscriptionID, clientID, clientSecret string } + +// NewAzureProvider 创建 Azure Provider,AccessKey 格式为 tenantId:subscriptionId:clientId。 // Logan.Li +func NewAzureProvider(accessKey, secretKey string) *AzureProvider { + parts := strings.Split(accessKey, ":") + p := &AzureProvider{clientSecret: secretKey} + if len(parts) == 3 { + p.tenantID, p.subscriptionID, p.clientID = parts[0], parts[1], parts[2] + } + return p +} + +// Name 返回 Azure 厂商标识。 // Logan.Li +func (p *AzureProvider) Name() string { return "azure" } + +// token 获取 ARM 所需的服务主体访问令牌。 // Logan.Li +func (p *AzureProvider) token() (string, error) { + if p.tenantID == "" || p.subscriptionID == "" || p.clientID == "" || p.clientSecret == "" { + return "", fmt.Errorf("Azure AccessKey 应为 租户ID:订阅ID:客户端ID,SecretKey 应为客户端密钥") + } + form := url.Values{"client_id": {p.clientID}, "client_secret": {p.clientSecret}, "grant_type": {"client_credentials"}, "scope": {"https://management.azure.com/.default"}} + resp, err := (&http.Client{Timeout: 30 * time.Second}).PostForm("https://login.microsoftonline.com/"+p.tenantID+"/oauth2/v2.0/token", form) + if err != nil { + return "", fmt.Errorf("获取 Azure 令牌失败: %w", err) + } + defer resp.Body.Close() + body, err := io.ReadAll(resp.Body) + if err != nil { + return "", err + } + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return "", fmt.Errorf("Azure 令牌接口返回 HTTP %d: %s", resp.StatusCode, string(body)) + } + var data struct { + Token string `json:"access_token"` + } + if err := json.Unmarshal(body, &data); err != nil || data.Token == "" { + return "", fmt.Errorf("解析 Azure 令牌失败") + } + return data.Token, nil +} + +// get 调用 Azure Resource Manager GET API。 // Logan.Li +func (p *AzureProvider) get(path string) ([]byte, error) { + token, err := p.token() + if err != nil { + return nil, err + } + req, err := http.NewRequest(http.MethodGet, "https://management.azure.com"+path, nil) + if err != nil { + return nil, err + } + req.Header.Set("Authorization", "Bearer "+token) + resp, err := (&http.Client{Timeout: 30 * time.Second}).Do(req) + if err != nil { + return nil, fmt.Errorf("请求 Azure API 失败: %w", err) + } + defer resp.Body.Close() + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, err + } + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return nil, fmt.Errorf("Azure API 返回 HTTP %d: %s", resp.StatusCode, string(body)) + } + return body, nil +} + +// post 调用 Azure Resource Manager JSON POST API。 // Logan.Li +func (p *AzureProvider) post(path string, payload interface{}) ([]byte, error) { + token, err := p.token() + if err != nil { + return nil, err + } + body, err := json.Marshal(payload) + if err != nil { + return nil, fmt.Errorf("编码 Azure 请求失败: %w", err) + } + req, err := http.NewRequest(http.MethodPost, "https://management.azure.com"+path, strings.NewReader(string(body))) + if err != nil { + return nil, err + } + req.Header.Set("Authorization", "Bearer "+token) + req.Header.Set("Content-Type", "application/json") + resp, err := (&http.Client{Timeout: 30 * time.Second}).Do(req) + if err != nil { + return nil, fmt.Errorf("请求 Azure API 失败: %w", err) + } + defer resp.Body.Close() + data, err := io.ReadAll(resp.Body) + if err != nil { + return nil, err + } + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return nil, fmt.Errorf("Azure API 返回 HTTP %d: %s", resp.StatusCode, string(data)) + } + return data, nil +} + +// ListRegions 获取 Azure 订阅可用区域。 // Logan.Li +func (p *AzureProvider) ListRegions() ([]CloudRegion, error) { + body, err := p.get("/subscriptions/" + p.subscriptionID + "/locations?api-version=2022-12-01") + if err != nil { + return nil, err + } + var result struct { + Value []struct { + Name string `json:"name"` + DisplayName string `json:"displayName"` + } `json:"value"` + } + if err := json.Unmarshal(body, &result); err != nil { + return nil, fmt.Errorf("解析 Azure 区域响应失败: %w", err) + } + regions := []CloudRegion{} + for _, item := range result.Value { + regions = append(regions, CloudRegion{RegionID: item.Name, LocalName: item.DisplayName, Endpoint: "https://management.azure.com", Status: "active"}) + } + return regions, nil +} + +// ListInstances 获取 Azure 指定区域的虚拟机并保留 ARM 提供的基础元数据。 // Logan.Li +func (p *AzureProvider) ListInstances(regionID string) ([]CloudInstance, error) { + body, err := p.get("/subscriptions/" + p.subscriptionID + "/providers/Microsoft.Compute/virtualMachines?api-version=2024-03-01") + if err != nil { + return nil, err + } + var result struct { + Value []struct { + ID string `json:"id"` + Name string `json:"name"` + Location string `json:"location"` + Properties struct { + Hardware struct { + VMSize string `json:"vmSize"` + } `json:"hardwareProfile"` + OS struct { + Type string `json:"osType"` + Name string `json:"computerName"` + } `json:"osProfile"` + Provisioning string `json:"provisioningState"` + } `json:"properties"` + } `json:"value"` + } + if err := json.Unmarshal(body, &result); err != nil { + return nil, fmt.Errorf("解析 Azure 实例响应失败: %w", err) + } + instances := []CloudInstance{} + for _, item := range result.Value { + if !strings.EqualFold(item.Location, regionID) { + continue + } + instances = append(instances, CloudInstance{InstanceID: item.ID, InstanceName: item.Name, Hostname: item.Properties.OS.Name, OSType: item.Properties.OS.Type, Status: azureState(item.Properties.Provisioning), RegionID: item.Location, InstanceType: item.Properties.Hardware.VMSize}) + } + return instances, nil +} + +// azureState 统一 Azure 实例状态,便于复用导入逻辑。 // Logan.Li +func azureState(state string) string { + if strings.EqualFold(state, "Succeeded") { + return "Running" + } + return state +} diff --git a/backend/cloud/azure_billing.go b/backend/cloud/azure_billing.go new file mode 100644 index 0000000..5065610 --- /dev/null +++ b/backend/cloud/azure_billing.go @@ -0,0 +1,105 @@ +package cloud + +import ( + "encoding/json" + "fmt" + "math" + "sort" + "strconv" + "strings" + "time" +) + +// FetchDailyBill 通过 Azure Cost Management Query 拉取订阅单日成本。 // Logan.Li +func (p *AzureProvider) FetchDailyBill(billingDate string) ([]CloudBillingRecord, error) { + start, err := time.Parse("2006-01-02", billingDate) + if err != nil { + return nil, fmt.Errorf("账单日期格式应为 YYYY-MM-DD") + } + if p.subscriptionID == "" { + return nil, fmt.Errorf("Azure AccessKey 应为 租户ID:订阅ID:客户端ID") + } + payload := map[string]interface{}{ + "type": "ActualCost", + "timeframe": "Custom", + "timePeriod": map[string]string{ + "from": start.Format("2006-01-02T00:00:00Z"), + "to": start.AddDate(0, 0, 1).Format("2006-01-02T00:00:00Z"), + }, + "dataset": map[string]interface{}{ + "granularity": "Daily", + "aggregation": map[string]interface{}{ + "totalCost": map[string]string{"name": "PreTaxCost", "function": "Sum"}, + }, + "grouping": []map[string]string{ + {"type": "Dimension", "name": "ServiceName"}, + {"type": "Dimension", "name": "ResourceLocation"}, + }, + }, + } + path := "/subscriptions/" + p.subscriptionID + "/providers/Microsoft.CostManagement/query?api-version=2023-11-01" + body, err := p.post(path, payload) + if err != nil { + return nil, err + } + var response struct { + Properties struct { + Columns []struct { + Name string `json:"name"` + } `json:"columns"` + Rows [][]interface{} `json:"rows"` + } `json:"properties"` + } + if err = json.Unmarshal(body, &response); err != nil { + return nil, fmt.Errorf("解析 Azure Cost Management 响应失败: %w", err) + } + columnIndex := map[string]int{} + for index, column := range response.Properties.Columns { + columnIndex[strings.ToLower(column.Name)] = index + } + valueAt := func(row []interface{}, names ...string) interface{} { + for _, name := range names { + if index, ok := columnIndex[strings.ToLower(name)]; ok && index < len(row) { + return row[index] + } + } + return nil + } + type aggregationKey struct { + service string + region string + } + aggregation := map[aggregationKey]float64{} + currency := "USD" + for _, row := range response.Properties.Rows { + cost, _ := strconv.ParseFloat(fmt.Sprint(valueAt(row, "PreTaxCost", "Cost", "totalCost")), 64) + if cost == 0 { + continue + } + service := firstNonEmpty(fmt.Sprint(valueAt(row, "ServiceName")), "其他") + region := firstNonEmpty(fmt.Sprint(valueAt(row, "ResourceLocation")), "全局") + if value := fmt.Sprint(valueAt(row, "Currency")); value != "" && value != "" { + currency = value + } + aggregation[aggregationKey{service: service, region: region}] += cost + } + keys := make([]aggregationKey, 0, len(aggregation)) + for key := range aggregation { + keys = append(keys, key) + } + sort.Slice(keys, func(i, j int) bool { + if keys[i].service == keys[j].service { + return keys[i].region < keys[j].region + } + return keys[i].service < keys[j].service + }) + records := make([]CloudBillingRecord, 0, len(keys)) + for _, key := range keys { + records = append(records, CloudBillingRecord{ + BillingDate: billingDate, Provider: "azure", Cost: math.Round(aggregation[key]*1000000) / 1000000, + Currency: currency, Service: key.service, Region: key.region, + Description: "云 API 自动同步(Azure Cost Management)", + }) + } + return records, nil +} diff --git a/backend/cloud/billing.go b/backend/cloud/billing.go index d43c996..7f70da4 100644 --- a/backend/cloud/billing.go +++ b/backend/cloud/billing.go @@ -17,7 +17,7 @@ type BillingFetcher interface { } // SupportedBillingProviders 已接入账单 API 的云厂商列表。 // Logan.Li -var SupportedBillingProviders = []string{"aliyun", "tencent"} +var SupportedBillingProviders = []string{"aliyun", "tencent", "aws", "huawei", "azure"} // NewBillingFetcher 根据云厂商创建账单拉取器 func NewBillingFetcher(provider, accessKey, secretKey string) BillingFetcher { @@ -26,6 +26,12 @@ func NewBillingFetcher(provider, accessKey, secretKey string) BillingFetcher { return NewAliyunProvider(accessKey, secretKey) case "tencent": return NewTencentProvider(accessKey, secretKey) + case "aws": + return NewAWSProvider(accessKey, secretKey) + case "huawei": + return NewHuaweiProvider(accessKey, secretKey) + case "azure": + return NewAzureProvider(accessKey, secretKey) } return nil } diff --git a/backend/cloud/huawei.go b/backend/cloud/huawei.go new file mode 100644 index 0000000..3a21093 --- /dev/null +++ b/backend/cloud/huawei.go @@ -0,0 +1,148 @@ +package cloud + +import ( + "encoding/hex" + "encoding/json" + "fmt" + "io" + "net/http" + "sort" + "strings" + "time" +) + +// HuaweiProvider 通过华为云 AK/SK 签名 API 同步项目区域与 ECS 实例。 // Logan.Li +type HuaweiProvider struct{ accessKey, secretKey string } + +// NewHuaweiProvider 创建华为云 Provider。 // Logan.Li +func NewHuaweiProvider(accessKey, secretKey string) *HuaweiProvider { + return &HuaweiProvider{accessKey, secretKey} +} + +// Name 返回华为云厂商标识。 // Logan.Li +func (p *HuaweiProvider) Name() string { return "huawei" } + +// signedGet 使用 SDK-HMAC-SHA256 调用华为云 GET API。 // Logan.Li +func (p *HuaweiProvider) signedGet(rawURL, _ string) ([]byte, error) { + u, err := http.NewRequest(http.MethodGet, rawURL, nil) + if err != nil { + return nil, fmt.Errorf("创建华为云请求失败: %w", err) + } + now := time.Now().UTC().Format("20060102T150405Z") + host := u.URL.Host + canonicalHeaders := "host:" + host + "\n" + "x-sdk-date:" + now + "\n" + signedHeaders := "host;x-sdk-date" + canonicalRequest := "GET\n" + u.URL.EscapedPath() + "\n" + u.URL.Query().Encode() + "\n" + canonicalHeaders + "\n" + signedHeaders + "\n" + sha256Hex("") + stringToSign := "SDK-HMAC-SHA256\n" + now + "\n" + sha256Hex(canonicalRequest) + signature := hex.EncodeToString(hmacSHA256([]byte(p.secretKey), stringToSign)) + u.Header.Set("X-Sdk-Date", now) + u.Header.Set("Authorization", "SDK-HMAC-SHA256 Access="+p.accessKey+", SignedHeaders="+signedHeaders+", Signature="+signature) + resp, err := (&http.Client{Timeout: 30 * time.Second}).Do(u) + if err != nil { + return nil, fmt.Errorf("请求华为云 API 失败: %w", err) + } + defer resp.Body.Close() + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("读取华为云响应失败: %w", err) + } + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return nil, fmt.Errorf("华为云 API 返回 HTTP %d: %s", resp.StatusCode, string(body)) + } + return body, nil +} + +// ListRegions 获取当前账号的项目列表,项目 ID 作为可用于 ECS 请求的区域代码。 // Logan.Li +func (p *HuaweiProvider) ListRegions() ([]CloudRegion, error) { + body, err := p.signedGet("https://iam.myhuaweicloud.com/v3/projects", "iam") + if err != nil { + return nil, err + } + var result struct { + Projects []struct { + ID string `json:"id"` + Name string `json:"name"` + } `json:"projects"` + } + if err := json.Unmarshal(body, &result); err != nil { + return nil, fmt.Errorf("解析华为云区域响应失败: %w", err) + } + regions := []CloudRegion{} + for _, project := range result.Projects { + if project.ID == "" || project.Name == "" { + continue + } + regions = append(regions, CloudRegion{RegionID: project.ID, LocalName: project.Name, Endpoint: "https://ecs." + project.Name + ".myhuaweicloud.com", Status: "active"}) + } + sort.Slice(regions, func(i, j int) bool { return regions[i].LocalName < regions[j].LocalName }) + return regions, nil +} + +// ListInstances 获取指定华为云项目中的 ECS 实例。 // Logan.Li +func (p *HuaweiProvider) ListInstances(projectID string) ([]CloudInstance, error) { + regions, err := p.ListRegions() + if err != nil { + return nil, err + } + regionName := "" + for _, region := range regions { + if region.RegionID == projectID { + regionName = region.LocalName + break + } + } + if regionName == "" { + return nil, fmt.Errorf("未找到华为云项目 %s", projectID) + } + body, err := p.signedGet("https://ecs."+regionName+".myhuaweicloud.com/v2.1/"+projectID+"/servers/detail", "ecs") + if err != nil { + return nil, err + } + var result struct { + Servers []struct { + ID string `json:"id"` + Name string `json:"name"` + Status string `json:"status"` + Flavor struct { + ID string `json:"id"` + } `json:"flavor"` + Created string `json:"created"` + Metadata map[string]string `json:"metadata"` + Addresses map[string][]struct { + Addr string `json:"addr"` + Type string `json:"OS-EXT-IPS:type"` + } `json:"addresses"` + Zone string `json:"OS-EXT-AZ:availability_zone"` + } `json:"servers"` + } + if err := json.Unmarshal(body, &result); err != nil { + return nil, fmt.Errorf("解析华为云实例响应失败: %w", err) + } + instances := []CloudInstance{} + for _, server := range result.Servers { + privateIP, publicIP := "", "" + for _, addresses := range server.Addresses { + for _, address := range addresses { + if address.Type == "floating" { + publicIP = address.Addr + } else if privateIP == "" { + privateIP = address.Addr + } + } + } + osType := server.Metadata["os_type"] + if osType == "" { + osType = server.Metadata["__os_type"] + } + instances = append(instances, CloudInstance{InstanceID: server.ID, InstanceName: server.Name, Hostname: server.Name, OSType: osType, Status: huaweiState(server.Status), RegionID: projectID, ZoneID: server.Zone, PrivateIP: privateIP, PublicIP: publicIP, InstanceType: server.Flavor.ID, CreationTime: server.Created}) + } + return instances, nil +} + +// huaweiState 统一华为云实例状态,便于复用导入逻辑。 // Logan.Li +func huaweiState(state string) string { + if strings.EqualFold(state, "ACTIVE") { + return "Running" + } + return state +} diff --git a/backend/cloud/huawei_billing.go b/backend/cloud/huawei_billing.go new file mode 100644 index 0000000..67b8baf --- /dev/null +++ b/backend/cloud/huawei_billing.go @@ -0,0 +1,98 @@ +package cloud + +import ( + "encoding/json" + "fmt" + "math" + "net/url" + "sort" + "strconv" + "strings" + "time" +) + +// FetchDailyBill 通过华为云 BSS 费用流水接口拉取单日账单。 // Logan.Li +func (p *HuaweiProvider) FetchDailyBill(billingDate string) ([]CloudBillingRecord, error) { + if _, err := time.Parse("2006-01-02", billingDate); err != nil { + return nil, fmt.Errorf("账单日期格式应为 YYYY-MM-DD") + } + type aggregationKey struct { + service string + region string + } + aggregation := map[aggregationKey]float64{} + const pageSize = 1000 + for offset := 0; ; offset += pageSize { + query := url.Values{ + "cycle": {billingDate[:7]}, + "bill_date_begin": {billingDate}, + "bill_date_end": {billingDate}, + "offset": {strconv.Itoa(offset)}, + "limit": {strconv.Itoa(pageSize)}, + } + body, err := p.signedGet("https://bss.myhuaweicloud.com/v2/bills/customer-bills/fee-records?"+query.Encode(), "bss") + if err != nil { + return nil, err + } + var response struct { + TotalCount int `json:"total_count"` + FeeRecords []struct { + CloudServiceType string `json:"cloud_service_type"` + CloudServiceTypeName string `json:"cloud_service_type_name"` + ResourceTypeName string `json:"resource_type_name"` + Region string `json:"region"` + RegionName string `json:"region_name"` + Amount json.RawMessage `json:"amount"` + OfficialAmount json.RawMessage `json:"official_amount"` + DiscountAmount json.RawMessage `json:"discount_amount"` + } `json:"fee_records"` + } + if err = json.Unmarshal(body, &response); err != nil { + return nil, fmt.Errorf("解析华为云 BSS 响应失败: %w", err) + } + for _, item := range response.FeeRecords { + cost := huaweiBillAmount(item.Amount) + if cost == 0 { + cost = huaweiBillAmount(item.OfficialAmount) - huaweiBillAmount(item.DiscountAmount) + } + if cost == 0 { + continue + } + service := firstNonEmpty(item.CloudServiceTypeName, item.ResourceTypeName, item.CloudServiceType, "其他") + region := firstNonEmpty(item.RegionName, item.Region, "全局") + aggregation[aggregationKey{service: service, region: region}] += cost + } + if len(response.FeeRecords) < pageSize || (response.TotalCount > 0 && offset+pageSize >= response.TotalCount) { + break + } + } + keys := make([]aggregationKey, 0, len(aggregation)) + for key := range aggregation { + keys = append(keys, key) + } + sort.Slice(keys, func(i, j int) bool { + if keys[i].service == keys[j].service { + return keys[i].region < keys[j].region + } + return keys[i].service < keys[j].service + }) + records := make([]CloudBillingRecord, 0, len(keys)) + for _, key := range keys { + records = append(records, CloudBillingRecord{ + BillingDate: billingDate, Provider: "huawei", Cost: math.Round(aggregation[key]*100) / 100, + Currency: "CNY", Service: key.service, Region: key.region, + Description: "云 API 自动同步(华为云 BSS)", + }) + } + return records, nil +} + +// huaweiBillAmount 兼容华为云金额字段的字符串和数字格式。 // Logan.Li +func huaweiBillAmount(raw json.RawMessage) float64 { + value := strings.Trim(strings.TrimSpace(string(raw)), "\"") + amount, err := strconv.ParseFloat(value, 64) + if err != nil { + return 0 + } + return amount +} diff --git a/backend/cloud/provider.go b/backend/cloud/provider.go index a30f175..3b15d05 100644 --- a/backend/cloud/provider.go +++ b/backend/cloud/provider.go @@ -42,12 +42,14 @@ func NewProvider(provider, accessKey, secretKey string) Provider { switch provider { case "aliyun": return NewAliyunProvider(accessKey, secretKey) + case "aws": + return NewAWSProvider(accessKey, secretKey) case "tencent": return NewTencentProvider(accessKey, secretKey) // 启用腾讯云 CVM 区域与实例同步。 // Logan.Li - // case "huawei": - // return NewHuaweiProvider(accessKey, secretKey) - // case "aws": - // return NewAWSProvider(accessKey, secretKey) + case "huawei": + return NewHuaweiProvider(accessKey, secretKey) + case "azure": + return NewAzureProvider(accessKey, secretKey) default: return nil } diff --git a/backend/cloud/tencent_test.go b/backend/cloud/tencent_test.go index dea1f25..49d9dc5 100644 --- a/backend/cloud/tencent_test.go +++ b/backend/cloud/tencent_test.go @@ -26,3 +26,12 @@ func TestTencentRetryDelay(t *testing.T) { t.Fatal("非限频错误不应重试") } } + +// TestBillingFetcherProviders 验证五家云厂商均已注册账单拉取器。 // Logan.Li +func TestBillingFetcherProviders(t *testing.T) { + for _, provider := range SupportedBillingProviders { + if fetcher := NewBillingFetcher(provider, "ak", "sk"); fetcher == nil { + t.Fatalf("云厂商 %s 未注册账单拉取器", provider) + } + } +} diff --git a/backend/controller/billing.go b/backend/controller/billing.go index d1d994e..e60be72 100644 --- a/backend/controller/billing.go +++ b/backend/controller/billing.go @@ -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) } diff --git a/backend/controller/cloud_accounts.go b/backend/controller/cloud_accounts.go index c67c24b..d4c51ad 100644 --- a/backend/controller/cloud_accounts.go +++ b/backend/controller/cloud_accounts.go @@ -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: "删除成功", }) } - diff --git a/backend/controller/cloud_sync.go b/backend/controller/cloud_sync.go index 1eb8bdb..89da7c3 100644 --- a/backend/controller/cloud_sync.go +++ b/backend/controller/cloud_sync.go @@ -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, diff --git a/backend/model/billing.go b/backend/model/billing.go index db465a3..57743cb 100644 --- a/backend/model/billing.go +++ b/backend/model/billing.go @@ -2,24 +2,23 @@ package model import ( "time" - ) // CloudBilling 云账单模型 - 每日成本记录 type CloudBilling struct { - ID uint `gorm:"primarykey" json:"id"` - CloudAccountID *uint `gorm:"index" json:"cloud_account_id"` - AccountName string `gorm:"size:128" json:"account_name"` - Provider string `gorm:"size:64;index" json:"provider"` // aliyun, tencent, aws, huawei, azure - BillingDate string `gorm:"size:10;index;not null" json:"billing_date"` // YYYY-MM-DD - Cost float64 `gorm:"type:decimal(12,2);default:0" json:"cost"` // 费用(元) - Currency string `gorm:"size:8;default:CNY" json:"currency"` // CNY, USD - Service string `gorm:"size:128" json:"service"` // ECS, RDS, OSS, CDN 等 - Region string `gorm:"size:64" json:"region"` - Tags string `gorm:"type:text" json:"tags"` // JSON - Description string `gorm:"size:500" json:"description"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` + ID uint `gorm:"primarykey" json:"id"` + CloudAccountID *uint `gorm:"index" json:"cloud_account_id"` + AccountName string `gorm:"size:128" json:"account_name"` + Provider string `gorm:"size:64;index" json:"provider"` // aliyun, tencent, aws, huawei, azure + BillingDate string `gorm:"size:10;index;not null" json:"billing_date"` // YYYY-MM-DD + Cost float64 `gorm:"type:decimal(12,2);default:0" json:"cost"` // 费用(元) + Currency string `gorm:"size:8;default:CNY" json:"currency"` // CNY, USD + Service string `gorm:"size:128" json:"service"` // ECS, RDS, OSS, CDN 等 + Region string `gorm:"size:64" json:"region"` + Tags string `gorm:"type:text" json:"tags"` // JSON + Description string `gorm:"size:500" json:"description"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` } func (CloudBilling) TableName() string { @@ -28,17 +27,17 @@ func (CloudBilling) TableName() string { // BillingAlertRule 账单预警规则 type BillingAlertRule struct { - ID uint `gorm:"primarykey" json:"id"` - Name string `gorm:"size:128;not null" json:"name"` - Provider string `gorm:"size:64" json:"provider"` // 空=所有云厂商 - CloudAccountID *uint `gorm:"index" json:"cloud_account_id"` // 空=所有账号 - Metric string `gorm:"size:32;default:daily" json:"metric"` // daily, monthly - Threshold float64 `gorm:"type:decimal(12,2)" json:"threshold"` // 阈值(元) - Operator string `gorm:"size:8;default:>" json:"operator"` // >, >=, <, <= - Enabled bool `gorm:"default:true" json:"enabled"` - LastTriggered *time.Time `json:"last_triggered"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` + ID uint `gorm:"primarykey" json:"id"` + Name string `gorm:"size:128;not null" json:"name"` + Provider string `gorm:"size:64" json:"provider"` // 空=所有云厂商 + CloudAccountID *uint `gorm:"index" json:"cloud_account_id"` // 空=所有账号 + Metric string `gorm:"size:32;default:daily" json:"metric"` // daily, monthly + Threshold float64 `gorm:"type:decimal(12,2)" json:"threshold"` // 阈值(元) + Operator string `gorm:"size:8;default:>" json:"operator"` // >, >=, <, <= + Enabled bool `gorm:"default:true" json:"enabled"` + LastTriggered *time.Time `json:"last_triggered"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` } func (BillingAlertRule) TableName() string { @@ -47,19 +46,20 @@ func (BillingAlertRule) TableName() string { // BillingStats 账单统计 type BillingStats struct { - TodayCost float64 `json:"today_cost"` - MonthCost float64 `json:"month_cost"` - YearCost float64 `json:"year_cost"` - YesterdayCost float64 `json:"yesterday_cost"` - MonthTrend []DailyCost `json:"month_trend"` - ProviderDist []ProviderCost `json:"provider_dist"` - Alerts []BillingAlert `json:"alerts"` + Currency string `json:"currency"` + TodayCost float64 `json:"today_cost"` + MonthCost float64 `json:"month_cost"` + YearCost float64 `json:"year_cost"` + YesterdayCost float64 `json:"yesterday_cost"` + MonthTrend []DailyCost `json:"month_trend"` + ProviderDist []ProviderCost `json:"provider_dist"` + Alerts []BillingAlert `json:"alerts"` } // DailyCost 每日成本 type DailyCost struct { - Date string `json:"date"` - Cost float64 `json:"cost"` + Date string `json:"date"` + Cost float64 `json:"cost"` } // ProviderCost 厂商成本分布 @@ -82,15 +82,15 @@ type BillingAlert struct { // CreateBillingRequest 创建账单记录请求 type CreateBillingRequest struct { - CloudAccountID *uint `json:"cloud_account_id"` - AccountName string `json:"account_name"` - Provider string `json:"provider" binding:"required"` - BillingDate string `json:"billing_date" binding:"required"` + CloudAccountID *uint `json:"cloud_account_id"` + AccountName string `json:"account_name"` + Provider string `json:"provider" binding:"required"` + BillingDate string `json:"billing_date" binding:"required"` Cost float64 `json:"cost" binding:"required"` - Currency string `json:"currency"` - Service string `json:"service"` - Region string `json:"region"` - Description string `json:"description"` + Currency string `json:"currency"` + Service string `json:"service"` + Region string `json:"region"` + Description string `json:"description"` } // CreateBillingAlertRequest 创建预警规则请求 diff --git a/backend/model/server.go b/backend/model/server.go index 8feeab3..2283260 100644 --- a/backend/model/server.go +++ b/backend/model/server.go @@ -6,40 +6,41 @@ import ( // Server 服务器模型 type Server struct { - ID uint `gorm:"primarykey" json:"id"` - Hostname string `gorm:"size:128;not null" json:"hostname"` - IP string `gorm:"size:64;not null" json:"ip"` - PublicIP string `gorm:"size:64" json:"public_ip"` - Port int `gorm:"default:22" json:"port"` - Username string `gorm:"size:64;default:root" json:"username"` - Password string `gorm:"size:255" json:"-"` - SSHKey string `gorm:"type:text" json:"-"` - AuthType string `gorm:"size:32;default:password" json:"auth_type"` // password, key - OSType string `gorm:"size:32;default:linux" json:"os_type"` // 操作系统类型:linux/windows // Logan.Li - Protocol string `gorm:"size:32;default:ssh" json:"protocol"` // 远程协议:ssh/rdp // Logan.Li - RDPPort int `gorm:"default:3389" json:"rdp_port"` // Windows RDP 端口 // Logan.Li - OS string `gorm:"size:64" json:"os"` - Kernel string `gorm:"size:64" json:"kernel"` - OSVersion string `gorm:"size:64" json:"os_version"` - Arch string `gorm:"size:32" json:"arch"` - CPU int `json:"cpu"` - Memory int64 `json:"memory"` // MB - Disk int64 `json:"disk"` // GB - Status string `gorm:"size:32;default:stopped" json:"status"` // running, stopped, maintenance, error - CPUUsage float64 `json:"cpu_usage"` - MemoryUsage float64 `json:"memory_usage"` - DiskUsage float64 `json:"disk_usage"` - Uptime int64 `json:"uptime"` // seconds - AgentVersion string `gorm:"size:32" json:"agent_version"` - AgentStatus string `gorm:"size:32;default:offline" json:"agent_status"` // online, offline - HasAuth bool `gorm:"-" json:"has_auth"` // 是否有认证凭据(密码或SSH密钥) — 不存DB,handler 计算后返回 // Logan.Li - RegionID *uint `json:"region_id"` - CloudID *uint `json:"cloud_id"` - Tags string `gorm:"type:text" json:"tags"` // JSON - Description string `gorm:"size:500" json:"description"` - LastCheckAt *time.Time `json:"last_check_at"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` + ID uint `gorm:"primarykey" json:"id"` + Hostname string `gorm:"size:128;not null" json:"hostname"` + IP string `gorm:"size:64;not null" json:"ip"` + PublicIP string `gorm:"size:64" json:"public_ip"` + Port int `gorm:"default:22" json:"port"` + Username string `gorm:"size:64;default:root" json:"username"` + Password string `gorm:"size:255" json:"-"` + SSHKey string `gorm:"type:text" json:"-"` + AuthType string `gorm:"size:32;default:password" json:"auth_type"` // password, key + OSType string `gorm:"size:32;default:linux" json:"os_type"` // 操作系统类型:linux/windows // Logan.Li + Protocol string `gorm:"size:32;default:ssh" json:"protocol"` // 远程协议:ssh/rdp // Logan.Li + RDPPort int `gorm:"default:3389" json:"rdp_port"` // Windows RDP 端口 // Logan.Li + OS string `gorm:"size:64" json:"os"` + Kernel string `gorm:"size:64" json:"kernel"` + OSVersion string `gorm:"size:64" json:"os_version"` + Arch string `gorm:"size:32" json:"arch"` + CPU int `json:"cpu"` + Memory int64 `json:"memory"` // MB + Disk int64 `json:"disk"` // GB + Status string `gorm:"size:32;default:stopped" json:"status"` // running, stopped, maintenance, error + CPUUsage float64 `json:"cpu_usage"` + MemoryUsage float64 `json:"memory_usage"` + DiskUsage float64 `json:"disk_usage"` + Uptime int64 `json:"uptime"` // seconds + AgentVersion string `gorm:"size:32" json:"agent_version"` + AgentStatus string `gorm:"size:32;default:offline" json:"agent_status"` // online, offline + HasAuth bool `gorm:"-" json:"has_auth"` // 是否有认证凭据(密码或SSH密钥) — 不存DB,handler 计算后返回 // Logan.Li + RegionID *uint `json:"region_id"` + CloudID *uint `json:"cloud_id"` + CloudAccountID *uint `gorm:"index" json:"cloud_account_id"` // 关联导入来源云账号,用于准确统计。 // Logan.Li + Tags string `gorm:"type:text" json:"tags"` // JSON + Description string `gorm:"size:500" json:"description"` + LastCheckAt *time.Time `json:"last_check_at"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` // 关联 Region *Region `json:"region,omitempty"` @@ -71,18 +72,19 @@ func (Cloud) TableName() string { // CloudAccount 云账号模型 type CloudAccount struct { - ID uint `gorm:"primarykey" json:"id"` - Name string `gorm:"size:128;not null" json:"name"` - Provider string `gorm:"size:64;not null" json:"provider"` // aws, aliyun, tencent, huawei, azure - AccessKey string `gorm:"size:255;not null" json:"-"` - SecretKey string `gorm:"size:255;not null" json:"-"` - Status string `gorm:"size:32;default:active" json:"status"` // active, inactive, error - RegionCount int `gorm:"default:0" json:"region_count"` - ServerCount int `gorm:"default:0" json:"server_count"` - MonthlyCost float64 `gorm:"default:0" json:"monthly_cost"` // 月费用(元),驱动仪表盘成本统计 - LastSyncAt *time.Time `json:"last_sync_at"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` + ID uint `gorm:"primarykey" json:"id"` + Name string `gorm:"size:128;not null" json:"name"` + Provider string `gorm:"size:64;not null" json:"provider"` // aws, aliyun, tencent, huawei, azure + AccessKey string `gorm:"size:255;not null" json:"-"` + SecretKey string `gorm:"size:255;not null" json:"-"` + Status string `gorm:"size:32;default:active" json:"status"` // active, inactive, error + RegionCount int `gorm:"default:0" json:"region_count"` + ServerCount int `gorm:"default:0" json:"server_count"` + MonthlyCost float64 `gorm:"default:0" json:"monthly_cost"` // 月费用(元),驱动仪表盘成本统计 + BillingCurrency string `gorm:"size:8;default:CNY" json:"billing_currency"` // 账单原始币种。 // Logan.Li + LastSyncAt *time.Time `json:"last_sync_at"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` } // TableName 表名 diff --git a/backend/pkg/billingsync/sync.go b/backend/pkg/billingsync/sync.go index 12f79bb..313c67e 100644 --- a/backend/pkg/billingsync/sync.go +++ b/backend/pkg/billingsync/sync.go @@ -95,8 +95,15 @@ func syncAccount(db *gorm.DB, account model.CloudAccount, billingDate string) (c accountID := account.ID accountName := account.Name + billingCurrency := records[0].Currency + if billingCurrency == "" { + billingCurrency = "CNY" + } for _, rec := range records { + if rec.Currency == "" { + rec.Currency = billingCurrency + } var existing model.CloudBilling findErr := db.Where( "cloud_account_id = ? AND billing_date = ? AND provider = ? AND service = ? AND region = ?", @@ -145,9 +152,12 @@ func syncAccount(db *gorm.DB, account model.CloudAccount, billingDate string) (c monthStart := billingDate[:7] + "-01" var monthCost float64 db.Model(&model.CloudBilling{}). - Where("cloud_account_id = ? AND billing_date >= ?", accountID, monthStart). + Where("cloud_account_id = ? AND billing_date >= ? AND currency = ?", accountID, monthStart, billingCurrency). Select("COALESCE(SUM(cost), 0)").Scan(&monthCost) - db.Model(&account).Update("monthly_cost", monthCost) + db.Model(&account).Updates(map[string]interface{}{ + "monthly_cost": monthCost, + "billing_currency": billingCurrency, + }) return created, updated, skipped, nil } diff --git a/frontend/src/api/cloud/billing.ts b/frontend/src/api/cloud/billing.ts index 7ea258a..9fb0b76 100644 --- a/frontend/src/api/cloud/billing.ts +++ b/frontend/src/api/cloud/billing.ts @@ -3,13 +3,13 @@ import { http, type ListResponse } from '@/utils/request'; export const billingApi = { - getStats: async () => { - return http.get('/cloud-billings/stats'); + getStats: async (currency = 'CNY') => { + return http.get('/cloud-billings/stats', { currency }); }, getCostAnalysis: async () => { return http.get('/cloud-billings/cost-analysis'); }, - list: async (params?: { provider?: string; cloud_account_id?: number; start_date?: string; end_date?: string; page?: number; page_size?: number }) => { + list: async (params?: { provider?: string; cloud_account_id?: number; currency?: string; start_date?: string; end_date?: string; page?: number; page_size?: number }) => { return http.get('/cloud-billings', params); }, create: async (data: Record) => { diff --git a/frontend/src/components/aiops/AIPredictiveAnalytics.tsx b/frontend/src/components/aiops/AIPredictiveAnalytics.tsx index 3575767..231a96a 100644 --- a/frontend/src/components/aiops/AIPredictiveAnalytics.tsx +++ b/frontend/src/components/aiops/AIPredictiveAnalytics.tsx @@ -71,7 +71,7 @@ export function AIPredictiveAnalytics() { dashboardStatsApi.getMetrics(72), serverApi.getServerStats(), billingApi.getStats().catch(() => null), - billingApi.list({ start_date: startDate, page_size: 500 }).catch(() => ({ list: [] })), + billingApi.list({ start_date: startDate, currency: 'CNY', page_size: 500 }).catch(() => ({ list: [] })), billingApi.listAlertRules().catch(() => ({ list: [] })), billingApi.getCostAnalysis().catch(() => null), ]); diff --git a/frontend/src/components/cloud/CloudAccountDetailSheet.tsx b/frontend/src/components/cloud/CloudAccountDetailSheet.tsx index 2b1075a..86347d9 100644 --- a/frontend/src/components/cloud/CloudAccountDetailSheet.tsx +++ b/frontend/src/components/cloud/CloudAccountDetailSheet.tsx @@ -25,6 +25,7 @@ import { cloudApi, billingApi } from '@/api'; import { CloudBrandIcon, getProviderColor } from '@/components/icons/CloudBrandIcon'; import type { JsonData } from '@/types/common'; +import { formatCurrencyByCode } from './CloudBillingHelpers'; interface CloudAccountDetailSheetProps { account: JsonData | null; @@ -141,6 +142,7 @@ export function CloudAccountDetailSheet({ const serverCount = account.server_count ?? account.serverCount ?? 0; const regionCount = account.region_count ?? account.regionCount ?? regions.length; const monthlyCost = account.monthly_cost ?? account.monthlyCost ?? 0; + const billingCurrency = account.billing_currency || account.billingCurrency || 'CNY'; const lastSync = account.last_sync_at || account.lastSyncAt || account.last_sync; return ( @@ -185,7 +187,7 @@ export function CloudAccountDetailSheet({

{t('cloud.monthlyFee')}

-

¥{Number(monthlyCost).toLocaleString()}

+

{formatCurrencyByCode(Number(monthlyCost), billingCurrency)}

{t('cloud.serverCount')}

@@ -262,7 +264,7 @@ export function CloudAccountDetailSheet({

{b.billing_date} · {b.region || '-'}

- ¥{Number(b.cost || 0).toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 })} + {formatCurrencyByCode(Number(b.cost || 0), b.currency)}
))} @@ -273,4 +275,4 @@ export function CloudAccountDetailSheet({ ); -} \ No newline at end of file +} diff --git a/frontend/src/components/cloud/CloudBillingHelpers.tsx b/frontend/src/components/cloud/CloudBillingHelpers.tsx index 1db593a..3de8e4e 100644 --- a/frontend/src/components/cloud/CloudBillingHelpers.tsx +++ b/frontend/src/components/cloud/CloudBillingHelpers.tsx @@ -39,7 +39,22 @@ export function ProviderDot({ provider, className = 'w-2 h-2' }: { provider: str } export function formatCurrency(n: number) { - return `¥${n.toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`; + return formatCurrencyByCode(n, 'CNY'); +} + +// 按账单原始币种格式化,避免 USD 等费用被错误显示成人民币。 // Logan.Li +export function formatCurrencyByCode(n: number, currency = 'CNY') { + try { + return new Intl.NumberFormat('zh-CN', { + style: 'currency', + currency: currency || 'CNY', + currencyDisplay: 'narrowSymbol', + minimumFractionDigits: 2, + maximumFractionDigits: 2, + }).format(Number(n) || 0); + } catch { + return `${currency || 'CNY'} ${(Number(n) || 0).toFixed(2)}`; + } } export function StatCard({ icon, label, value, trend, trendLabel, alert }: { @@ -106,4 +121,4 @@ export function TrendChart({ data }: { data: { date: string; cost: number }[] }) ); -} \ No newline at end of file +} diff --git a/frontend/src/components/cloud/cloud-account/AccountCard.tsx b/frontend/src/components/cloud/cloud-account/AccountCard.tsx index bb01e2d..bd44d79 100644 --- a/frontend/src/components/cloud/cloud-account/AccountCard.tsx +++ b/frontend/src/components/cloud/cloud-account/AccountCard.tsx @@ -16,6 +16,7 @@ import { import { CloudBrandIcon, getProviderColor } from '@/components/icons/CloudBrandIcon'; import type { JsonData } from '@/types/common'; import type { ActionLoading } from './types'; +import { formatCurrencyByCode } from '../CloudBillingHelpers'; export interface AccountCardProps { account: JsonData; @@ -46,6 +47,7 @@ export function AccountCard({ const serverCount = account.server_count ?? account.serverCount ?? 0; const lastSync = account.last_sync || account.lastSync || account.created_at || '-'; const monthlyCost = account.monthly_cost ?? account.monthlyCost ?? 0; + const billingCurrency = account.billing_currency || account.billingCurrency || 'CNY'; return ( @@ -120,7 +122,7 @@ export function AccountCard({

{t('cloud.monthlyFee')}

- ¥{Number(monthlyCost).toLocaleString()} + {formatCurrencyByCode(Number(monthlyCost), billingCurrency)}

diff --git a/frontend/src/components/cloud/cloud-account/AccountFormDialog.tsx b/frontend/src/components/cloud/cloud-account/AccountFormDialog.tsx index 7c348dd..9e1f0a5 100644 --- a/frontend/src/components/cloud/cloud-account/AccountFormDialog.tsx +++ b/frontend/src/components/cloud/cloud-account/AccountFormDialog.tsx @@ -34,6 +34,14 @@ export function AccountFormDialog({ onSubmit, }: AccountFormDialogProps) { const { t } = useTranslation(); + const accessKeyPlaceholder = formData.provider === 'azure' + ? t('cloud.azureAccessKeyPlaceholder') + : formData.provider === 'aws' + ? t('cloud.awsAccessKeyPlaceholder') + : t('cloud.defaultAccessKeyPlaceholder'); + const secretKeyPlaceholder = formData.provider === 'azure' + ? t('cloud.azureSecretKeyPlaceholder') + : t('cloud.defaultSecretKeyPlaceholder'); return ( @@ -87,7 +95,7 @@ export function AccountFormDialog({ )} onFormDataChange({ ...formData, access_key: e.target.value })} className="dg-input font-mono text-sm" @@ -103,7 +111,7 @@ export function AccountFormDialog({ onFormDataChange({ ...formData, secret_key: e.target.value })} className="dg-input font-mono text-sm" diff --git a/frontend/src/components/cloud/cloud-billing/RecordsTab.tsx b/frontend/src/components/cloud/cloud-billing/RecordsTab.tsx index 2ea7d67..444940a 100644 --- a/frontend/src/components/cloud/cloud-billing/RecordsTab.tsx +++ b/frontend/src/components/cloud/cloud-billing/RecordsTab.tsx @@ -13,7 +13,7 @@ import { Plus, Trash2, Pencil, RefreshCw, Wallet } from 'lucide-react'; import { ContentPanel } from '@/components/layout/ContentPanel'; import { PageLoading } from '@/components/layout/PageLoading'; import { PageEmpty } from '@/components/layout/PageEmpty'; -import { formatCurrency, providerLabels, ProviderDot } from '../CloudBillingHelpers'; +import { formatCurrencyByCode, providerLabels, ProviderDot } from '../CloudBillingHelpers'; import type { JsonData } from '@/types/common'; /** 成本记录筛选工具栏 — 云厂商下拉 + 日期范围 + 查询按钮 // Logan.Li */ @@ -123,7 +123,7 @@ export function RecordsTab({ {r.service || '-'} {r.region || '-'} {r.account_name || '-'} - {formatCurrency(r.cost)} + {formatCurrencyByCode(r.cost, r.currency)} {r.description || '-'}
diff --git a/frontend/src/components/layout/Sidebar.tsx b/frontend/src/components/layout/Sidebar.tsx index 38fd112..1d250c8 100644 --- a/frontend/src/components/layout/Sidebar.tsx +++ b/frontend/src/components/layout/Sidebar.tsx @@ -388,7 +388,7 @@ export function Sidebar() {
{/* Menu Items */} - +