package cloud import ( "encoding/hex" "encoding/json" "fmt" "io" "math" "net/http" "sort" "strconv" "strings" "time" ) // FetchDailyBill 通过 Cost Explorer 拉取 AWS 单日服务和区域费用。 // Logan.Li func (p *AWSProvider) FetchDailyBill(billingDate string) ([]CloudBillingRecord, error) { start, err := time.Parse("2006-01-02", billingDate) if err != nil { return nil, fmt.Errorf("账单日期格式应为 YYYY-MM-DD") } type aggregationKey struct { service string region string } aggregation := map[aggregationKey]float64{} nextToken := "" currency := "USD" for { payload := map[string]interface{}{ "TimePeriod": map[string]string{"Start": billingDate, "End": start.AddDate(0, 0, 1).Format("2006-01-02")}, "Granularity": "DAILY", "Metrics": []string{"UnblendedCost"}, "GroupBy": []map[string]string{ {"Type": "DIMENSION", "Key": "SERVICE"}, {"Type": "DIMENSION", "Key": "REGION"}, }, } if nextToken != "" { payload["NextPageToken"] = nextToken } body, callErr := p.callCostExplorer(payload) if callErr != nil { return nil, callErr } var response struct { NextPageToken string `json:"NextPageToken"` ResultsByTime []struct { Groups []struct { Keys []string `json:"Keys"` Metrics map[string]struct { Amount string `json:"Amount"` Unit string `json:"Unit"` } `json:"Metrics"` } `json:"Groups"` } `json:"ResultsByTime"` } if err = json.Unmarshal(body, &response); err != nil { return nil, fmt.Errorf("解析 AWS Cost Explorer 响应失败: %w", err) } for _, period := range response.ResultsByTime { for _, group := range period.Groups { metric := group.Metrics["UnblendedCost"] cost, _ := strconv.ParseFloat(metric.Amount, 64) if cost == 0 { continue } if metric.Unit != "" { currency = metric.Unit } service, region := "其他", "全局" if len(group.Keys) > 0 && group.Keys[0] != "" { service = group.Keys[0] } if len(group.Keys) > 1 && group.Keys[1] != "" && group.Keys[1] != "NoRegion" { region = group.Keys[1] } aggregation[aggregationKey{service: service, region: region}] += cost } } nextToken = response.NextPageToken if nextToken == "" { break } } keys := make([]aggregationKey, 0, len(aggregation)) for key := range aggregation { keys = append(keys, key) } sort.Slice(keys, func(i, j int) bool { if keys[i].service == keys[j].service { return keys[i].region < keys[j].region } return keys[i].service < keys[j].service }) records := make([]CloudBillingRecord, 0, len(keys)) for _, key := range keys { records = append(records, CloudBillingRecord{ BillingDate: billingDate, Provider: "aws", Cost: math.Round(aggregation[key]*1000000) / 1000000, Currency: currency, Service: key.service, Region: key.region, Description: "云 API 自动同步(AWS Cost Explorer)", }) } return records, nil } // callCostExplorer 使用 Signature V4 调用 AWS Cost Explorer JSON API。 // Logan.Li func (p *AWSProvider) callCostExplorer(payload interface{}) ([]byte, error) { body, err := json.Marshal(payload) if err != nil { return nil, fmt.Errorf("编码 AWS 账单请求失败: %w", err) } const host = "ce.us-east-1.amazonaws.com" const target = "AWSInsightsIndexService.GetCostAndUsage" now := time.Now().UTC() amzDate, date := now.Format("20060102T150405Z"), now.Format("20060102") canonicalHeaders := "content-type:application/x-amz-json-1.1\nhost:" + host + "\nx-amz-date:" + amzDate + "\nx-amz-target:" + target + "\n" signedHeaders := "content-type;host;x-amz-date;x-amz-target" canonicalRequest := "POST\n/\n\n" + canonicalHeaders + "\n" + signedHeaders + "\n" + sha256Hex(string(body)) credentialScope := date + "/us-east-1/ce/aws4_request" stringToSign := "AWS4-HMAC-SHA256\n" + amzDate + "\n" + credentialScope + "\n" + sha256Hex(canonicalRequest) kDate := hmacSHA256([]byte("AWS4"+p.secretKey), date) kRegion := hmacSHA256(kDate, "us-east-1") kService := hmacSHA256(kRegion, "ce") signature := hex.EncodeToString(hmacSHA256(hmacSHA256(kService, "aws4_request"), stringToSign)) req, err := http.NewRequest(http.MethodPost, "https://"+host+"/", strings.NewReader(string(body))) if err != nil { return nil, fmt.Errorf("创建 AWS 账单请求失败: %w", err) } req.Header.Set("Content-Type", "application/x-amz-json-1.1") req.Header.Set("X-Amz-Date", amzDate) req.Header.Set("X-Amz-Target", target) req.Header.Set("Authorization", "AWS4-HMAC-SHA256 Credential="+p.accessKey+"/"+credentialScope+", SignedHeaders="+signedHeaders+", Signature="+signature) resp, err := (&http.Client{Timeout: 30 * time.Second}).Do(req) if err != nil { return nil, fmt.Errorf("请求 AWS Cost Explorer 失败: %w", err) } defer resp.Body.Close() data, err := io.ReadAll(resp.Body) if err != nil { return nil, fmt.Errorf("读取 AWS Cost Explorer 响应失败: %w", err) } if resp.StatusCode < 200 || resp.StatusCode >= 300 { return nil, fmt.Errorf("AWS Cost Explorer 返回 HTTP %d: %s", resp.StatusCode, string(data)) } return data, nil }