chore: 统一本地服务器与远程仓库源码

- 合并 main 与 master 历史并以服务器结构为基线
- 保留迁移、数据保留、AWS 账单与部署安全能力
- 建立 SDD 规格和 CI 验证闭环
This commit is contained in:
Logan.Li committed 2026-08-05 20:03:25 +08:00
commit fd60361d93
460 files changed
+37492 -23871

No files matched your search

+81 -105
View File
@@ -1,147 +1,123 @@
package cloud
import (
"bytes"
"encoding/hex"
"encoding/json"
"fmt"
"io"
"math"
"net/http"
"sort"
"strconv"
"strings"
"time"
)
// FetchDailyBill 通过 Cost Explorer 拉取 AWS 单日服务和区域费用。 // Logan.Li
// FetchDailyBill 通过 AWS Cost Explorer 按服务和区域同步指定日的未摊销成本。 // Logan.Li
func (p *AWSProvider) FetchDailyBill(billingDate string) ([]CloudBillingRecord, error) {
start, err := time.Parse("2006-01-02", billingDate)
day, err := time.Parse("2006-01-02", billingDate)
if err != nil {
return nil, fmt.Errorf("账单日期格式应为 YYYY-MM-DD")
return nil, fmt.Errorf("账单日期格式错误: %w", err)
}
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
payload, err := json.Marshal(map[string]interface{}{
"TimePeriod": map[string]string{
"Start": day.Format("2006-01-02"),
"End": day.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"},
},
})
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)",
})
if err != nil {
return nil, fmt.Errorf("构造 AWS Cost Explorer 请求失败: %w", err)
}
return records, nil
body, err := p.callCostExplorer(payload)
if err != nil {
return nil, err
}
return parseAWSCostExplorerBill(billingDate, body)
}
// 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)
}
// callCostExplorer 使用 SigV4 调用 Cost Explorer;该服务固定使用 us-east-1。 // Logan.Li
func (p *AWSProvider) callCostExplorer(payload []byte) ([]byte, error) {
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"
date := now.Format("20060102")
amzDate := now.Format("20060102T150405Z")
payloadHash := sha256Hex(string(payload))
canonicalHeaders := "content-type:application/x-amz-json-1.1\n" +
"host:" + host + "\n" +
"x-amz-date:" + amzDate + "\n" +
"x-amz-target:AWSInsightsIndexService.GetCostAndUsage\n"
signedHeaders := "content-type;host;x-amz-date;x-amz-target"
canonicalRequest := "POST\n/\n\n" + canonicalHeaders + "\n" + signedHeaders + "\n" + sha256Hex(string(body))
canonicalRequest := "POST\n/\n\n" + canonicalHeaders + "\n" + signedHeaders + "\n" + payloadHash
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)))
signingKey := hmacSHA256(kService, "aws4_request")
signature := hex.EncodeToString(hmacSHA256(signingKey, stringToSign))
req, err := http.NewRequest(http.MethodPost, "https://"+host+"/", bytes.NewReader(payload))
if err != nil {
return nil, fmt.Errorf("创建 AWS 账单请求失败: %w", err)
return nil, fmt.Errorf("创建 AWS Cost Explorer 请求失败: %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("X-Amz-Target", "AWSInsightsIndexService.GetCostAndUsage")
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)
body, 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))
if resp.StatusCode < http.StatusOK || resp.StatusCode >= http.StatusMultipleChoices {
return nil, fmt.Errorf("AWS Cost Explorer 返回 HTTP %d: %s", resp.StatusCode, string(body))
}
return data, nil
return body, nil
}
func parseAWSCostExplorerBill(billingDate string, body []byte) ([]CloudBillingRecord, error) {
var response struct {
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)
}
records := make([]CloudBillingRecord, 0)
for _, result := range response.ResultsByTime {
for _, group := range result.Groups {
metric, ok := group.Metrics["UnblendedCost"]
if !ok {
continue
}
amount, err := strconv.ParseFloat(metric.Amount, 64)
if err != nil {
return nil, fmt.Errorf("解析 AWS 成本金额失败: %w", err)
}
service, region := "AWS", "global"
if len(group.Keys) > 0 && group.Keys[0] != "" {
service = group.Keys[0]
}
if len(group.Keys) > 1 && group.Keys[1] != "" {
region = group.Keys[1]
}
records = append(records, CloudBillingRecord{BillingDate: billingDate, Provider: "aws", Cost: amount, Currency: metric.Unit, Service: service, Region: region, Description: "AWS Cost Explorer UnblendedCost"})
}
}
return records, nil
}
-31
View File
@@ -79,37 +79,6 @@ func (p *AzureProvider) get(path string) ([]byte, error) {
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")
-105
View File
@@ -1,105 +0,0 @@
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 != "<nil>" {
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
}
+1 -5
View File
@@ -17,7 +17,7 @@ type BillingFetcher interface {
}
// SupportedBillingProviders 已接入账单 API 的云厂商列表。 // Logan.Li
var SupportedBillingProviders = []string{"aliyun", "tencent", "aws", "huawei", "azure"}
var SupportedBillingProviders = []string{"aliyun", "tencent", "aws"}
// NewBillingFetcher 根据云厂商创建账单拉取器
func NewBillingFetcher(provider, accessKey, secretKey string) BillingFetcher {
@@ -28,10 +28,6 @@ func NewBillingFetcher(provider, accessKey, secretKey string) BillingFetcher {
return NewTencentProvider(accessKey, secretKey)
case "aws":
return NewAWSProvider(accessKey, secretKey)
case "huawei":
return NewHuaweiProvider(accessKey, secretKey)
case "azure":
return NewAzureProvider(accessKey, secretKey)
}
return nil
}
+19 -29
View File
@@ -8,42 +8,37 @@ import (
"net/http"
"sort"
"strings"
"sync"
"time"
)
// HuaweiProvider 通过华为云 AK/SK 签名 API 同步项目区域与 ECS 实例。 // Logan.Li
type HuaweiProvider struct {
accessKey, secretKey string
regionMu sync.RWMutex
regionByProject map[string]string
}
type HuaweiProvider struct{ accessKey, secretKey string }
// NewHuaweiProvider 创建华为云 Provider。 // Logan.Li
func NewHuaweiProvider(accessKey, secretKey string) *HuaweiProvider {
return &HuaweiProvider{accessKey: accessKey, secretKey: secretKey, regionByProject: make(map[string]string)}
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) {
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"
canonicalURI := u.URL.EscapedPath()
if !strings.HasSuffix(canonicalURI, "/") {
canonicalURI += "/"
}
canonicalRequest := "GET\n" + canonicalURI + "\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))
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)
@@ -77,33 +72,28 @@ func (p *HuaweiProvider) ListRegions() ([]CloudRegion, error) {
return nil, fmt.Errorf("解析华为云区域响应失败: %w", err)
}
regions := []CloudRegion{}
regionByProject := make(map[string]string, len(result.Projects))
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"})
regionByProject[project.ID] = project.Name
}
p.regionMu.Lock()
p.regionByProject = regionByProject
p.regionMu.Unlock()
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) {
p.regionMu.RLock()
regionName := p.regionByProject[projectID]
p.regionMu.RUnlock()
if regionName == "" {
if _, err := p.ListRegions(); err != nil {
return nil, err
regions, err := p.ListRegions()
if err != nil {
return nil, err
}
regionName := ""
for _, region := range regions {
if region.RegionID == projectID {
regionName = region.LocalName
break
}
p.regionMu.RLock()
regionName = p.regionByProject[projectID]
p.regionMu.RUnlock()
}
if regionName == "" {
return nil, fmt.Errorf("未找到华为云项目 %s", projectID)
-133
View File
@@ -1,133 +0,0 @@
package cloud
import (
"encoding/json"
"fmt"
"math"
"net/url"
"sort"
"strconv"
"strings"
"time"
)
type huaweiBillRecord struct {
ServiceTypeCode string `json:"service_type_code"`
ServiceTypeName string `json:"service_type_name"`
CloudServiceType string `json:"cloud_service_type"`
CloudServiceTypeName string `json:"cloud_service_type_name"`
ResourceTypeName string `json:"resource_type_name"`
RegionCode string `json:"region_code"`
Region string `json:"region"`
RegionName string `json:"region_name"`
Amount json.RawMessage `json:"amount"`
ConsumeAmount json.RawMessage `json:"consume_amount"`
OfficialAmount json.RawMessage `json:"official_amount"`
DiscountAmount json.RawMessage `json:"discount_amount"`
}
type huaweiBillPage struct {
TotalCount int `json:"total_count"`
Currency string `json:"currency"`
Records []huaweiBillRecord `json:"records"`
FeeRecords []huaweiBillRecord `json:"fee_records"`
}
// 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{}
currency := "CNY"
const pageSize = 1000
for offset := 0; ; offset += pageSize {
query := url.Values{
"bill_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
}
page, err := decodeHuaweiBillPage(body)
if err != nil {
return nil, err
}
if page.Currency != "" {
currency = page.Currency
}
records := page.Records
if len(records) == 0 {
records = page.FeeRecords
}
for _, item := range records {
cost := huaweiBillRecordCost(item)
if cost == 0 {
continue
}
service := firstNonEmpty(item.ServiceTypeName, item.CloudServiceTypeName, item.ResourceTypeName, item.ServiceTypeCode, item.CloudServiceType, "其他")
region := firstNonEmpty(item.RegionName, item.RegionCode, item.Region, "全局")
aggregation[aggregationKey{service: service, region: region}] += cost
}
if len(records) < pageSize || (page.TotalCount > 0 && offset+pageSize >= page.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: currency, Service: key.service, Region: key.region,
Description: "云 API 自动同步(华为云 BSS)",
})
}
return records, nil
}
// decodeHuaweiBillPage 兼容华为云 records 与旧版 fee_records 响应。 // Logan.Li
func decodeHuaweiBillPage(body []byte) (huaweiBillPage, error) {
var page huaweiBillPage
if err := json.Unmarshal(body, &page); err != nil {
return page, fmt.Errorf("解析华为云 BSS 响应失败: %w", err)
}
return page, nil
}
// huaweiBillRecordCost 依次读取实际消费、流水金额和官网价减优惠。 // Logan.Li
func huaweiBillRecordCost(item huaweiBillRecord) float64 {
if cost := huaweiBillAmount(item.ConsumeAmount); cost != 0 {
return cost
}
if cost := huaweiBillAmount(item.Amount); cost != 0 {
return cost
}
return huaweiBillAmount(item.OfficialAmount) - huaweiBillAmount(item.DiscountAmount)
}
// 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
}
-45
View File
@@ -1,45 +0,0 @@
package cloud
import (
"testing"
)
func TestDecodeHuaweiBillPageRecords(t *testing.T) {
body := []byte(`{
"currency":"CNY",
"total_count":1,
"records":[{
"service_type_name":"弹性云服务器",
"region_name":"华南-广州",
"consume_amount":"12.34"
}]
}`)
page, err := decodeHuaweiBillPage(body)
if err != nil {
t.Fatalf("解析华为云账单失败: %v", err)
}
if page.TotalCount != 1 || page.Currency != "CNY" || len(page.Records) != 1 {
t.Fatalf("账单分页字段错误: %+v", page)
}
record := page.Records[0]
if record.ServiceTypeName != "弹性云服务器" || record.RegionName != "华南-广州" {
t.Fatalf("账单维度字段错误: %+v", record)
}
if cost := huaweiBillRecordCost(record); cost != 12.34 {
t.Fatalf("实际消费金额错误: %v", cost)
}
}
func TestDecodeHuaweiBillPageFeeRecordsCompatibility(t *testing.T) {
body := []byte(`{"total_count":1,"fee_records":[{"official_amount":20,"discount_amount":3}]}`)
page, err := decodeHuaweiBillPage(body)
if err != nil {
t.Fatalf("解析旧版华为云账单失败: %v", err)
}
if len(page.FeeRecords) != 1 {
t.Fatalf("旧版 fee_records 未解析: %+v", page)
}
if cost := huaweiBillRecordCost(page.FeeRecords[0]); cost != 17 {
t.Fatalf("官网价减优惠金额错误: %v", cost)
}
}
+3 -3
View File
@@ -43,13 +43,13 @@ func NewProvider(provider, accessKey, secretKey string) Provider {
case "aliyun":
return NewAliyunProvider(accessKey, secretKey)
case "aws":
return NewAWSProvider(accessKey, secretKey)
return NewAWSProvider(accessKey, secretKey) // 启用 AWS EC2 区域与实例同步。 // Logan.Li
case "tencent":
return NewTencentProvider(accessKey, secretKey) // 启用腾讯云 CVM 区域与实例同步。 // Logan.Li
case "huawei":
return NewHuaweiProvider(accessKey, secretKey)
return NewHuaweiProvider(accessKey, secretKey) // 启用华为云 ECS 区域与实例同步。 // Logan.Li
case "azure":
return NewAzureProvider(accessKey, secretKey)
return NewAzureProvider(accessKey, secretKey) // 启用 Azure VM 区域与实例同步。 // Logan.Li
default:
return nil
}
+9 -23
View File
@@ -1,8 +1,6 @@
package cloud
import (
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
@@ -126,12 +124,12 @@ func (p *TencentProvider) callServiceOnce(service, host, version, action, region
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" + tencentSHA256Hex(string(body))
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" + tencentSHA256Hex(canonicalRequest)
secretDate := tencentHMACSHA256([]byte("TC3"+p.secretKey), date)
secretService := tencentHMACSHA256(secretDate, service)
signature := hex.EncodeToString(tencentHMACSHA256(tencentHMACSHA256(secretService, "tc3_request"), stringToSign))
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)
@@ -217,7 +215,8 @@ func (p *TencentProvider) ListInstances(regionID string) ([]CloudInstance, error
return nil, fmt.Errorf("解析腾讯云实例响应失败: %w", err)
}
for _, item := range result.Response.InstanceSet {
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: tencentFirstString(item.Private), PublicIP: tencentFirstString(item.Public), InstanceType: item.Type, CPU: item.CPU, Memory: item.Memory * 1024, CreationTime: item.Created})
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
@@ -226,8 +225,8 @@ func (p *TencentProvider) ListInstances(regionID string) ([]CloudInstance, error
return instances, nil
}
// tencentFirstString 获取 API 列表中的首个 IP。 // Logan.Li
func tencentFirstString(values []string) string {
// firstString 获取 API 列表中的首个 IP。 // Logan.Li
func firstString(values []string) string {
if len(values) == 0 {
return ""
}
@@ -241,16 +240,3 @@ func tencentState(state string) string {
}
return state
}
// tencentSHA256Hex 计算腾讯云 TC3 签名的 SHA-256 摘要。 // Logan.Li
func tencentSHA256Hex(value string) string {
sum := sha256.Sum256([]byte(value))
return hex.EncodeToString(sum[:])
}
// tencentHMACSHA256 计算腾讯云 TC3 签名的 HMAC-SHA256。 // Logan.Li
func tencentHMACSHA256(key []byte, value string) []byte {
mac := hmac.New(sha256.New, key)
_, _ = mac.Write([]byte(value))
return mac.Sum(nil)
}
+1 -3
View File
@@ -23,11 +23,9 @@ func (p *TencentProvider) FetchDailyBill(billingDate string) ([]CloudBillingReco
const pageSize = 100
for offset := 0; ; offset += pageSize {
// DescribeBillDetail 不支持 Day 参数,使用账期和起止时间限定单日范围。 // Logan.Li
// 腾讯云账单明细仅按账期查询,不支持按日参数。 // Logan.Li
body, err := p.callService("billing", "billing.tencentcloudapi.com", "2018-07-09", "DescribeBillDetail", "", map[string]interface{}{
"Month": billingDate[:7],
"BeginTime": billingDate + " 00:00:00",
"EndTime": billingDate + " 23:59:59",
"Offset": offset,
"Limit": pageSize,
"NeedRecordNum": 1,
-9
View File
@@ -26,12 +26,3 @@ 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)
}
}
}