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

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

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

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

No files matched your search

+177
View File
@@ -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
}