feat: 完善多云资产与腾讯云账单同步
- 接入 AWS、腾讯云、华为云和 Azure 区域及主机发现 - 接入腾讯云账单分页、限流与组件费用汇总 - 批量账单仅同步已支持厂商并优化云账号操作
This commit is contained in:
1 parent
74fa122319
commit
0b785da8ae
10 files changed
+821
-20
No files matched your search
@@ -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)
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -16,13 +16,16 @@ type BillingFetcher interface {
|
|||||||
FetchDailyBill(billingDate string) ([]CloudBillingRecord, error)
|
FetchDailyBill(billingDate string) ([]CloudBillingRecord, error)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SupportedBillingProviders 已接入账单 API 的云厂商列表。 // Logan.Li
|
||||||
|
var SupportedBillingProviders = []string{"aliyun", "tencent"}
|
||||||
|
|
||||||
// NewBillingFetcher 根据云厂商创建账单拉取器
|
// NewBillingFetcher 根据云厂商创建账单拉取器
|
||||||
func NewBillingFetcher(provider, accessKey, secretKey string) BillingFetcher {
|
func NewBillingFetcher(provider, accessKey, secretKey string) BillingFetcher {
|
||||||
switch provider {
|
switch provider {
|
||||||
case "aliyun":
|
case "aliyun":
|
||||||
if p := NewAliyunProvider(accessKey, secretKey); p != nil {
|
return NewAliyunProvider(accessKey, secretKey)
|
||||||
return p
|
case "tencent":
|
||||||
}
|
return NewTencentProvider(accessKey, secretKey)
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
+12
-10
@@ -19,10 +19,10 @@ type CloudInstance struct {
|
|||||||
|
|
||||||
// CloudRegion 云平台区域(跨平台统一结构)
|
// CloudRegion 云平台区域(跨平台统一结构)
|
||||||
type CloudRegion struct {
|
type CloudRegion struct {
|
||||||
RegionID string `json:"region_id"`
|
RegionID string `json:"region_id"`
|
||||||
LocalName string `json:"local_name"`
|
LocalName string `json:"local_name"`
|
||||||
Endpoint string `json:"endpoint"`
|
Endpoint string `json:"endpoint"`
|
||||||
Status string `json:"status"`
|
Status string `json:"status"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// Provider 云平台 Provider 接口
|
// Provider 云平台 Provider 接口
|
||||||
@@ -42,12 +42,14 @@ func NewProvider(provider, accessKey, secretKey string) Provider {
|
|||||||
switch provider {
|
switch provider {
|
||||||
case "aliyun":
|
case "aliyun":
|
||||||
return NewAliyunProvider(accessKey, secretKey)
|
return NewAliyunProvider(accessKey, secretKey)
|
||||||
// case "tencent":
|
case "aws":
|
||||||
// return NewTencentProvider(accessKey, secretKey)
|
return NewAWSProvider(accessKey, secretKey) // 启用 AWS EC2 区域与实例同步。 // Logan.Li
|
||||||
// case "huawei":
|
case "tencent":
|
||||||
// return NewHuaweiProvider(accessKey, secretKey)
|
return NewTencentProvider(accessKey, secretKey) // 启用腾讯云 CVM 区域与实例同步。 // Logan.Li
|
||||||
// case "aws":
|
case "huawei":
|
||||||
// return NewAWSProvider(accessKey, secretKey)
|
return NewHuaweiProvider(accessKey, secretKey) // 启用华为云 ECS 区域与实例同步。 // Logan.Li
|
||||||
|
case "azure":
|
||||||
|
return NewAzureProvider(accessKey, secretKey) // 启用 Azure VM 区域与实例同步。 // Logan.Li
|
||||||
default:
|
default:
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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 ""
|
||||||
|
}
|
||||||
@@ -37,6 +37,9 @@ func SyncDate(db *gorm.DB, billingDate string, accountID *uint) Result {
|
|||||||
q := db.Where("status = ?", "active")
|
q := db.Where("status = ?", "active")
|
||||||
if accountID != nil {
|
if accountID != nil {
|
||||||
q = q.Where("id = ?", *accountID)
|
q = q.Where("id = ?", *accountID)
|
||||||
|
} else {
|
||||||
|
// 批量同步仅处理已接入账单 API 的厂商,避免不支持厂商造成无效失败。 // Logan.Li
|
||||||
|
q = q.Where("provider IN ?", cloud.SupportedBillingProviders)
|
||||||
}
|
}
|
||||||
q.Find(&accounts)
|
q.Find(&accounts)
|
||||||
result.Accounts = len(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)
|
db.Model(&account).Update("monthly_cost", monthCost)
|
||||||
|
|
||||||
return created, updated, skipped, nil
|
return created, updated, skipped, nil
|
||||||
}
|
}
|
||||||
@@ -50,6 +50,17 @@ function getProviderLabels(t: (key: string) => string): Record<string, string> {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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() {
|
export function CloudAccountManager() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const [accounts, setAccounts] = useState<JsonData[]>([]);
|
const [accounts, setAccounts] = useState<JsonData[]>([]);
|
||||||
@@ -76,6 +87,7 @@ export function CloudAccountManager() {
|
|||||||
const [actionLoading, setActionLoading] = useState<Record<string, string>>({});
|
const [actionLoading, setActionLoading] = useState<Record<string, string>>({});
|
||||||
|
|
||||||
const providerLabels = getProviderLabels(t);
|
const providerLabels = getProviderLabels(t);
|
||||||
|
const credentialHints = getCredentialHints(formData.provider);
|
||||||
|
|
||||||
const setCardAction = (accountId: string | number, action: string | null) => {
|
const setCardAction = (accountId: string | number, action: string | null) => {
|
||||||
setActionLoading((prev) => {
|
setActionLoading((prev) => {
|
||||||
@@ -475,11 +487,11 @@ export function CloudAccountManager() {
|
|||||||
|
|
||||||
<div className="space-y-1.5">
|
<div className="space-y-1.5">
|
||||||
<Label>
|
<Label>
|
||||||
AccessKey
|
{formData.provider === 'azure' ? 'Azure 标识' : 'AccessKey'}
|
||||||
{editingAccount && <span className="text-xs dg-text-muted ml-2">{t('cloud.keepBlank')}</span>}
|
{editingAccount && <span className="text-xs dg-text-muted ml-2">{t('cloud.keepBlank')}</span>}
|
||||||
</Label>
|
</Label>
|
||||||
<Input
|
<Input
|
||||||
placeholder="LTAI..."
|
placeholder={credentialHints.accessKey}
|
||||||
value={formData.access_key}
|
value={formData.access_key}
|
||||||
onChange={(e) => setFormData({ ...formData, access_key: e.target.value })}
|
onChange={(e) => setFormData({ ...formData, access_key: e.target.value })}
|
||||||
className="dg-input font-mono text-sm"
|
className="dg-input font-mono text-sm"
|
||||||
@@ -488,18 +500,22 @@ export function CloudAccountManager() {
|
|||||||
|
|
||||||
<div className="space-y-1.5">
|
<div className="space-y-1.5">
|
||||||
<Label>
|
<Label>
|
||||||
SecretKey
|
{formData.provider === 'azure' ? 'Azure 客户端密钥' : 'SecretKey'}
|
||||||
{editingAccount && <span className="text-xs dg-text-muted ml-2">{t('cloud.keepBlank')}</span>}
|
{editingAccount && <span className="text-xs dg-text-muted ml-2">{t('cloud.keepBlank')}</span>}
|
||||||
</Label>
|
</Label>
|
||||||
<Input
|
<Input
|
||||||
type="password"
|
type="password"
|
||||||
placeholder="••••••••"
|
placeholder={credentialHints.secretKey}
|
||||||
value={formData.secret_key}
|
value={formData.secret_key}
|
||||||
onChange={(e) => setFormData({ ...formData, secret_key: e.target.value })}
|
onChange={(e) => setFormData({ ...formData, secret_key: e.target.value })}
|
||||||
className="dg-input font-mono text-sm"
|
className="dg-input font-mono text-sm"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{formData.provider === 'azure' && (
|
||||||
|
<p className="text-xs dg-text-muted">Azure 同步使用服务主体:标识填写“租户ID:订阅ID:客户端ID”,密钥填写客户端密钥。</p>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className="grid grid-cols-2 gap-4">
|
<div className="grid grid-cols-2 gap-4">
|
||||||
<div className="space-y-1.5">
|
<div className="space-y-1.5">
|
||||||
<Label>{t('cloud.monthlyFeeUnit')}</Label>
|
<Label>{t('cloud.monthlyFeeUnit')}</Label>
|
||||||
|
|||||||
@@ -294,7 +294,6 @@ export function CloudServerImport({ open, onClose, initialAccountId }: CloudServ
|
|||||||
{ id: 1, title: t('server.cloudProvider'), icon: Cloud },
|
{ id: 1, title: t('server.cloudProvider'), icon: Cloud },
|
||||||
{ id: 2, title: t('server.cloudAccount'), icon: Key },
|
{ id: 2, title: t('server.cloudAccount'), icon: Key },
|
||||||
{ id: 3, title: t('server.selectRegion'), icon: MapPin },
|
{ id: 3, title: t('server.selectRegion'), icon: MapPin },
|
||||||
{ id: 4, title: t('server.importConfirm'), icon: CheckCircle },
|
|
||||||
];
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
Reference in new issue
Block a user