From 0b785da8ae70931b1e11ef09937b59ecd7c0e959 Mon Sep 17 00:00:00 2001 From: "Logan.Li" Date: Sat, 25 Jul 2026 14:45:19 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=AE=8C=E5=96=84=E5=A4=9A=E4=BA=91?= =?UTF-8?q?=E8=B5=84=E4=BA=A7=E4=B8=8E=E8=85=BE=E8=AE=AF=E4=BA=91=E8=B4=A6?= =?UTF-8?q?=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 区域及主机发现 - 接入腾讯云账单分页、限流与组件费用汇总 - 批量账单仅同步已支持厂商并优化云账号操作 --- backend/cloud/aws.go | 162 ++++++++++++++++ backend/cloud/azure.go | 146 ++++++++++++++ backend/cloud/billing.go | 11 +- backend/cloud/huawei.go | 152 +++++++++++++++ backend/cloud/provider.go | 22 ++- backend/cloud/tencent.go | 179 ++++++++++++++++++ backend/cloud/tencent_billing.go | 139 ++++++++++++++ backend/pkg/billingsync/sync.go | 5 +- .../components/cloud/CloudAccountManager.tsx | 24 ++- .../components/server/CloudServerImport.tsx | 1 - 10 files changed, 821 insertions(+), 20 deletions(-) create mode 100644 backend/cloud/aws.go create mode 100644 backend/cloud/azure.go create mode 100644 backend/cloud/huawei.go create mode 100644 backend/cloud/tencent.go create mode 100644 backend/cloud/tencent_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/azure.go b/backend/cloud/azure.go new file mode 100644 index 0000000..0177871 --- /dev/null +++ b/backend/cloud/azure.go @@ -0,0 +1,146 @@ +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 +} + +// 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/billing.go b/backend/cloud/billing.go index 6da6050..d43c996 100644 --- a/backend/cloud/billing.go +++ b/backend/cloud/billing.go @@ -16,13 +16,16 @@ type BillingFetcher interface { FetchDailyBill(billingDate string) ([]CloudBillingRecord, error) } +// SupportedBillingProviders 已接入账单 API 的云厂商列表。 // Logan.Li +var SupportedBillingProviders = []string{"aliyun", "tencent"} + // NewBillingFetcher 根据云厂商创建账单拉取器 func NewBillingFetcher(provider, accessKey, secretKey string) BillingFetcher { switch provider { case "aliyun": - if p := NewAliyunProvider(accessKey, secretKey); p != nil { - return p - } + return NewAliyunProvider(accessKey, secretKey) + case "tencent": + return NewTencentProvider(accessKey, secretKey) } return nil -} \ No newline at end of file +} diff --git a/backend/cloud/huawei.go b/backend/cloud/huawei.go new file mode 100644 index 0000000..15b5315 --- /dev/null +++ b/backend/cloud/huawei.go @@ -0,0 +1,152 @@ +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, service 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") + date := now[:8] + 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("") + credentialScope := date + "/" + service + "/sdk_request" + stringToSign := "SDK-HMAC-SHA256\n" + now + "\n" + credentialScope + "\n" + sha256Hex(canonicalRequest) + kDate := hmacSHA256([]byte(p.secretKey), date) + kService := hmacSHA256(kDate, service) + signature := hex.EncodeToString(hmacSHA256(hmacSHA256(kService, "sdk_request"), 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/provider.go b/backend/cloud/provider.go index a0e96ba..7edb8de 100644 --- a/backend/cloud/provider.go +++ b/backend/cloud/provider.go @@ -19,10 +19,10 @@ type CloudInstance struct { // CloudRegion 云平台区域(跨平台统一结构) type CloudRegion struct { - RegionID string `json:"region_id"` - LocalName string `json:"local_name"` - Endpoint string `json:"endpoint"` - Status string `json:"status"` + RegionID string `json:"region_id"` + LocalName string `json:"local_name"` + Endpoint string `json:"endpoint"` + Status string `json:"status"` } // Provider 云平台 Provider 接口 @@ -42,12 +42,14 @@ func NewProvider(provider, accessKey, secretKey string) Provider { switch provider { case "aliyun": return NewAliyunProvider(accessKey, secretKey) - // case "tencent": - // return NewTencentProvider(accessKey, secretKey) - // case "huawei": - // return NewHuaweiProvider(accessKey, secretKey) - // case "aws": - // return NewAWSProvider(accessKey, secretKey) + case "aws": + return NewAWSProvider(accessKey, secretKey) // 启用 AWS EC2 区域与实例同步。 // Logan.Li + case "tencent": + return NewTencentProvider(accessKey, secretKey) // 启用腾讯云 CVM 区域与实例同步。 // Logan.Li + case "huawei": + return NewHuaweiProvider(accessKey, secretKey) // 启用华为云 ECS 区域与实例同步。 // Logan.Li + case "azure": + return NewAzureProvider(accessKey, secretKey) // 启用 Azure VM 区域与实例同步。 // Logan.Li default: return nil } diff --git a/backend/cloud/tencent.go b/backend/cloud/tencent.go new file mode 100644 index 0000000..e99e419 --- /dev/null +++ b/backend/cloud/tencent.go @@ -0,0 +1,179 @@ +package cloud + +import ( + "encoding/hex" + "encoding/json" + "fmt" + "io" + "net/http" + "sort" + "strings" + "time" +) + +// TencentProvider 通过腾讯云 TC3-HMAC-SHA256 API 同步 CVM 资源。 // Logan.Li +type TencentProvider struct{ accessKey, secretKey string } + +// tencentInstancesResponse 描述腾讯云 CVM 实例分页响应。 // Logan.Li +type tencentInstancesResponse struct { + Response struct { + TotalCount int `json:"TotalCount"` + InstanceSet []struct { + ID string `json:"InstanceId"` + Name string `json:"InstanceName"` + Type string `json:"InstanceType"` + State string `json:"InstanceState"` + Placement struct { + Zone string `json:"Zone"` + } `json:"Placement"` + Created string `json:"CreatedTime"` + OS string `json:"OsName"` + Private []string `json:"PrivateIpAddresses"` + Public []string `json:"PublicIpAddresses"` + CPU int `json:"CPU"` + Memory int `json:"Memory"` + } `json:"InstanceSet"` + } `json:"Response"` +} + +// NewTencentProvider 创建腾讯云 Provider。 // Logan.Li +func NewTencentProvider(accessKey, secretKey string) *TencentProvider { + return &TencentProvider{accessKey, secretKey} +} + +// Name 返回腾讯云厂商标识。 // Logan.Li +func (p *TencentProvider) Name() string { return "tencent" } + +// call 调用腾讯云 CVM API。 // Logan.Li +func (p *TencentProvider) call(action string, payload interface{}) ([]byte, error) { + return p.callService("cvm", "cvm.tencentcloudapi.com", "2017-03-12", action, "ap-guangzhou", payload) +} + +// callInRegion 调用指定区域的腾讯云 CVM API,签名区域必须与请求区域一致。 // Logan.Li +func (p *TencentProvider) callInRegion(region, action string, payload interface{}) ([]byte, error) { + return p.callService("cvm", "cvm.tencentcloudapi.com", "2017-03-12", action, region, payload) +} + +// callService 使用 TC3-HMAC-SHA256 调用腾讯云服务 API。 // Logan.Li +func (p *TencentProvider) callService(service, host, version, action, region string, payload interface{}) ([]byte, error) { + body, err := json.Marshal(payload) + if err != nil { + return nil, fmt.Errorf("编码腾讯云请求失败: %w", err) + } + now := time.Now() + timestamp, date := now.Unix(), now.UTC().Format("2006-01-02") + canonicalHeaders := "content-type:application/json; charset=utf-8\nhost:" + host + "\n" + signedHeaders := "content-type;host" + canonicalRequest := "POST\n/\n\n" + canonicalHeaders + "\n" + signedHeaders + "\n" + sha256Hex(string(body)) + credentialScope := date + "/" + service + "/tc3_request" + stringToSign := "TC3-HMAC-SHA256\n" + fmt.Sprint(timestamp) + "\n" + credentialScope + "\n" + sha256Hex(canonicalRequest) + secretDate := hmacSHA256([]byte("TC3"+p.secretKey), date) + secretService := hmacSHA256(secretDate, service) + signature := hex.EncodeToString(hmacSHA256(hmacSHA256(secretService, "tc3_request"), stringToSign)) + req, err := http.NewRequest(http.MethodPost, "https://"+host, strings.NewReader(string(body))) + if err != nil { + return nil, fmt.Errorf("创建腾讯云请求失败: %w", err) + } + req.Header.Set("Content-Type", "application/json; charset=utf-8") + req.Header.Set("Host", host) + req.Header.Set("X-TC-Action", action) + req.Header.Set("X-TC-Version", version) + req.Header.Set("X-TC-Timestamp", fmt.Sprint(timestamp)) + if region != "" { + req.Header.Set("X-TC-Region", region) + } + req.Header.Set("Authorization", "TC3-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("请求腾讯云 API 失败: %w", err) + } + defer resp.Body.Close() + data, 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(data)) + } + var envelope struct { + Response struct { + Error *struct { + Code string `json:"Code"` + Message string `json:"Message"` + } `json:"Error"` + } `json:"Response"` + } + if json.Unmarshal(data, &envelope) == nil && envelope.Response.Error != nil { + return nil, fmt.Errorf("腾讯云 API 错误 %s: %s", envelope.Response.Error.Code, envelope.Response.Error.Message) + } + return data, nil +} + +// ListRegions 获取腾讯云 CVM 区域。 // Logan.Li +func (p *TencentProvider) ListRegions() ([]CloudRegion, error) { + body, err := p.call("DescribeRegions", map[string]string{}) + if err != nil { + return nil, err + } + var result struct { + Response struct { + RegionSet []struct { + Region string `json:"Region"` + Name string `json:"RegionName"` + } `json:"RegionSet"` + } `json:"Response"` + } + if err := json.Unmarshal(body, &result); err != nil { + return nil, fmt.Errorf("解析腾讯云区域响应失败: %w", err) + } + regions := make([]CloudRegion, 0, len(result.Response.RegionSet)) + for _, r := range result.Response.RegionSet { + regions = append(regions, CloudRegion{RegionID: r.Region, LocalName: r.Name, Endpoint: "https://cvm.tencentcloudapi.com", Status: "active"}) + } + sort.Slice(regions, func(i, j int) bool { return regions[i].RegionID < regions[j].RegionID }) + return regions, nil +} + +// ListInstances 获取腾讯云指定区域的 CVM 实例。 // Logan.Li +func (p *TencentProvider) ListInstances(regionID string) ([]CloudInstance, error) { + instances := []CloudInstance{} + const pageSize = 100 + for offset := 0; ; offset += pageSize { + // 腾讯云 CVM 区域由 X-TC-Region 公共请求头传递,请求体不重复传 Region。 // Logan.Li + body, err := p.callInRegion(regionID, "DescribeInstances", map[string]interface{}{ + "Limit": pageSize, + "Offset": offset, + }) + if err != nil { + return nil, err + } + var result tencentInstancesResponse + if err := json.Unmarshal(body, &result); err != nil { + return nil, fmt.Errorf("解析腾讯云实例响应失败: %w", err) + } + for _, item := range result.Response.InstanceSet { + privateIP, publicIP := firstString(item.Private), firstString(item.Public) + instances = append(instances, CloudInstance{InstanceID: item.ID, InstanceName: item.Name, Hostname: item.Name, OSType: item.OS, Status: tencentState(item.State), RegionID: regionID, ZoneID: item.Placement.Zone, PrivateIP: privateIP, PublicIP: publicIP, InstanceType: item.Type, CPU: item.CPU, Memory: item.Memory * 1024, CreationTime: item.Created}) + } + if len(result.Response.InstanceSet) == 0 || len(instances) >= result.Response.TotalCount { + break + } + } + return instances, nil +} + +// firstString 获取 API 列表中的首个 IP。 // Logan.Li +func firstString(values []string) string { + if len(values) == 0 { + return "" + } + return values[0] +} + +// tencentState 统一腾讯云实例状态,便于复用导入逻辑。 // Logan.Li +func tencentState(state string) string { + if strings.EqualFold(state, "RUNNING") { + return "Running" + } + return state +} diff --git a/backend/cloud/tencent_billing.go b/backend/cloud/tencent_billing.go new file mode 100644 index 0000000..e47987e --- /dev/null +++ b/backend/cloud/tencent_billing.go @@ -0,0 +1,139 @@ +package cloud + +import ( + "encoding/json" + "fmt" + "math" + "sort" + "strconv" + "strings" + "time" +) + +// FetchDailyBill 拉取指定日期的腾讯云日账单,并按产品和地域汇总。 // Logan.Li +func (p *TencentProvider) FetchDailyBill(billingDate string) ([]CloudBillingRecord, error) { + if len(billingDate) != 10 { + return nil, fmt.Errorf("账单日期格式应为 YYYY-MM-DD") + } + + type aggregationKey struct { + service string + region string + } + aggregation := map[aggregationKey]float64{} + const pageSize = 100 + + for offset := 0; ; offset += pageSize { + // 腾讯云账单明细仅按账期查询,不支持按日参数。 // Logan.Li + body, err := p.callService("billing", "billing.tencentcloudapi.com", "2018-07-09", "DescribeBillDetail", "", map[string]interface{}{ + "Month": billingDate[:7], + "Offset": offset, + "Limit": pageSize, + "NeedRecordNum": 1, + }) + if err != nil { + return nil, err + } + + var response tencentBillDetailResponse + if err := json.Unmarshal(body, &response); err != nil { + return nil, fmt.Errorf("解析腾讯云账单响应失败: %w", err) + } + for _, detail := range response.Response.DetailSet { + cost := tencentBillAmount(detail.RealTotalCost) + if cost == 0 { + cost = tencentBillAmount(detail.RealCost) + } + if cost == 0 { + cost = tencentBillAmount(detail.CashPayAmount) + } + if cost == 0 { + // 腾讯云明细账单将组件费用放在 ComponentSet,逐项汇总实际费用。 // Logan.Li + for _, component := range detail.ComponentSet { + componentCost := tencentBillAmount(component.RealCost) + if componentCost == 0 { + componentCost = tencentBillAmount(component.CashPayAmount) + } + cost += componentCost + } + } + if cost <= 0 { + continue + } + service := firstNonEmpty(detail.ProductCodeName, detail.BusinessCodeName, detail.ProductCode, "其他") + region := firstNonEmpty(detail.RegionName, "全局") + aggregation[aggregationKey{service: service, region: region}] += cost + } + + if len(response.Response.DetailSet) < pageSize || (response.Response.Total > 0 && offset+pageSize >= response.Response.Total) { + break + } + // 腾讯云账单接口频率上限为每秒 5 次,分页请求限速为每秒 4 次。 // Logan.Li + time.Sleep(250 * time.Millisecond) + } + + 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: "tencent", + Cost: math.Round(aggregation[key]*100) / 100, + Currency: "CNY", + Service: key.service, + Region: key.region, + Description: "云 API 自动同步(腾讯云 DescribeBillDetail)", + }) + } + return records, nil +} + +// tencentBillDetailResponse 描述腾讯云费用中心明细账单响应。 // Logan.Li +type tencentBillDetailResponse struct { + Response struct { + Total int `json:"Total"` + DetailSet []struct { + ProductCode string `json:"ProductCode"` + ProductCodeName string `json:"ProductCodeName"` + BusinessCodeName string `json:"BusinessCodeName"` + RegionName string `json:"RegionName"` + RealTotalCost json.RawMessage `json:"RealTotalCost"` + RealCost json.RawMessage `json:"RealCost"` + CashPayAmount json.RawMessage `json:"CashPayAmount"` + ComponentSet []struct { + RealCost json.RawMessage `json:"RealCost"` + CashPayAmount json.RawMessage `json:"CashPayAmount"` + } `json:"ComponentSet"` + } `json:"DetailSet"` + } `json:"Response"` +} + +// tencentBillAmount 兼容腾讯云账单金额字段为字符串或数字的响应。 // Logan.Li +func tencentBillAmount(raw json.RawMessage) float64 { + value := strings.Trim(strings.TrimSpace(string(raw)), "\"") + amount, err := strconv.ParseFloat(value, 64) + if err != nil { + return 0 + } + return amount +} + +// firstNonEmpty 返回首个非空文本,用于兼容不同账单产品字段。 // Logan.Li +func firstNonEmpty(values ...string) string { + for _, value := range values { + if text := strings.TrimSpace(value); text != "" { + return text + } + } + return "" +} diff --git a/backend/pkg/billingsync/sync.go b/backend/pkg/billingsync/sync.go index 3c40cb2..ae5d2be 100644 --- a/backend/pkg/billingsync/sync.go +++ b/backend/pkg/billingsync/sync.go @@ -37,6 +37,9 @@ func SyncDate(db *gorm.DB, billingDate string, accountID *uint) Result { q := db.Where("status = ?", "active") if accountID != nil { q = q.Where("id = ?", *accountID) + } else { + // 批量同步仅处理已接入账单 API 的厂商,避免不支持厂商造成无效失败。 // Logan.Li + q = q.Where("provider IN ?", cloud.SupportedBillingProviders) } q.Find(&accounts) result.Accounts = len(accounts) @@ -138,4 +141,4 @@ func syncAccount(db *gorm.DB, account model.CloudAccount, billingDate string) (c db.Model(&account).Update("monthly_cost", monthCost) return created, updated, skipped, nil -} \ No newline at end of file +} diff --git a/frontend/src/components/cloud/CloudAccountManager.tsx b/frontend/src/components/cloud/CloudAccountManager.tsx index ace6d96..5f2ce6d 100644 --- a/frontend/src/components/cloud/CloudAccountManager.tsx +++ b/frontend/src/components/cloud/CloudAccountManager.tsx @@ -50,6 +50,17 @@ function getProviderLabels(t: (key: string) => string): Record { }; } +// getCredentialHints 按厂商提示凭据格式,Azure 使用服务主体三段标识。 // Logan.Li +function getCredentialHints(provider: string) { + if (provider === 'azure') { + return { accessKey: '租户ID:订阅ID:客户端ID', secretKey: '客户端密钥' }; + } + if (provider === 'aws') return { accessKey: 'AKIA...', secretKey: 'AWS Secret Access Key' }; + if (provider === 'tencent') return { accessKey: '腾讯云 SecretId', secretKey: '腾讯云 SecretKey' }; + if (provider === 'huawei') return { accessKey: '华为云 Access Key', secretKey: '华为云 Secret Access Key' }; + return { accessKey: 'LTAI...', secretKey: '阿里云 AccessKey Secret' }; +} + export function CloudAccountManager() { const { t } = useTranslation(); const [accounts, setAccounts] = useState([]); @@ -76,6 +87,7 @@ export function CloudAccountManager() { const [actionLoading, setActionLoading] = useState>({}); const providerLabels = getProviderLabels(t); + const credentialHints = getCredentialHints(formData.provider); const setCardAction = (accountId: string | number, action: string | null) => { setActionLoading((prev) => { @@ -475,11 +487,11 @@ export function CloudAccountManager() {
setFormData({ ...formData, access_key: e.target.value })} className="dg-input font-mono text-sm" @@ -488,18 +500,22 @@ export function CloudAccountManager() {
setFormData({ ...formData, secret_key: e.target.value })} className="dg-input font-mono text-sm" />
+ {formData.provider === 'azure' && ( +

Azure 同步使用服务主体:标识填写“租户ID:订阅ID:客户端ID”,密钥填写客户端密钥。

+ )} +
diff --git a/frontend/src/components/server/CloudServerImport.tsx b/frontend/src/components/server/CloudServerImport.tsx index cf36741..12ec2a0 100644 --- a/frontend/src/components/server/CloudServerImport.tsx +++ b/frontend/src/components/server/CloudServerImport.tsx @@ -294,7 +294,6 @@ export function CloudServerImport({ open, onClose, initialAccountId }: CloudServ { id: 1, title: t('server.cloudProvider'), icon: Cloud }, { id: 2, title: t('server.cloudAccount'), icon: Key }, { id: 3, title: t('server.selectRegion'), icon: MapPin }, - { id: 4, title: t('server.importConfirm'), icon: CheckCircle }, ]; return (