fix: 恢复腾讯云账单同步并完善成本预测

- 接入腾讯云单日账单、全局限频和指数退避

- 修正月末费用预测并增加账期提示和费用归因

- 补充腾讯云限频及账单归因回归测试
This commit is contained in:
Logan.Li committed 2026-07-29 05:44:40 +08:00
1 parent 88c41f0b2b
commit dd3770a66c
15 files changed
+610 -17

No files matched your search

+138
View File
@@ -0,0 +1,138 @@
package cloud
import (
"encoding/json"
"fmt"
"math"
"sort"
"strconv"
"strings"
)
// 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 {
// DescribeBillDetail 不支持 Day 参数,使用账期和起止时间限定单日范围。 // 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,
})
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
}
}
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 ""
}