fix: 恢复腾讯云账单同步并完善成本预测
- 接入腾讯云单日账单、全局限频和指数退避 - 修正月末费用预测并增加账期提示和费用归因 - 补充腾讯云限频及账单归因回归测试
This commit is contained in:
1 parent
88c41f0b2b
commit
dd3770a66c
15 files changed
+610
-17
No files matched your search
@@ -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
|
||||||
}
|
}
|
||||||
@@ -5,17 +5,39 @@ import (
|
|||||||
"crypto/sha256"
|
"crypto/sha256"
|
||||||
"encoding/hex"
|
"encoding/hex"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
"sort"
|
"sort"
|
||||||
"strings"
|
"strings"
|
||||||
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
// TencentProvider 通过腾讯云 TC3-HMAC-SHA256 API 同步 CVM 资源。 // Logan.Li
|
// TencentProvider 通过腾讯云 TC3-HMAC-SHA256 API 同步 CVM 资源。 // Logan.Li
|
||||||
type TencentProvider struct{ accessKey, secretKey string }
|
type TencentProvider struct{ accessKey, secretKey string }
|
||||||
|
|
||||||
|
const (
|
||||||
|
tencentRequestInterval = 260 * time.Millisecond
|
||||||
|
tencentMaxAttempts = 4
|
||||||
|
)
|
||||||
|
|
||||||
|
var tencentLimiter = struct {
|
||||||
|
sync.Mutex
|
||||||
|
next time.Time
|
||||||
|
}{}
|
||||||
|
|
||||||
|
// tencentAPIError 保留腾讯云错误码,便于对限频错误做退避重试。 // Logan.Li
|
||||||
|
type tencentAPIError struct {
|
||||||
|
Code string
|
||||||
|
Message string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *tencentAPIError) Error() string {
|
||||||
|
return fmt.Sprintf("腾讯云 API 错误 %s: %s", e.Code, e.Message)
|
||||||
|
}
|
||||||
|
|
||||||
// tencentInstancesResponse 描述腾讯云 CVM 实例分页响应。 // Logan.Li
|
// tencentInstancesResponse 描述腾讯云 CVM 实例分页响应。 // Logan.Li
|
||||||
type tencentInstancesResponse struct {
|
type tencentInstancesResponse struct {
|
||||||
Response struct {
|
Response struct {
|
||||||
@@ -58,6 +80,44 @@ func (p *TencentProvider) callInRegion(region, action string, payload interface{
|
|||||||
|
|
||||||
// callService 使用 TC3-HMAC-SHA256 调用腾讯云服务 API。 // Logan.Li
|
// callService 使用 TC3-HMAC-SHA256 调用腾讯云服务 API。 // Logan.Li
|
||||||
func (p *TencentProvider) callService(service, host, version, action, region string, payload interface{}) ([]byte, error) {
|
func (p *TencentProvider) callService(service, host, version, action, region string, payload interface{}) ([]byte, error) {
|
||||||
|
var lastErr error
|
||||||
|
for attempt := 0; attempt < tencentMaxAttempts; attempt++ {
|
||||||
|
waitTencentRequestSlot()
|
||||||
|
data, err := p.callServiceOnce(service, host, version, action, region, payload)
|
||||||
|
if err == nil {
|
||||||
|
return data, nil
|
||||||
|
}
|
||||||
|
lastErr = err
|
||||||
|
delay, retry := tencentRetryDelay(err, attempt)
|
||||||
|
if !retry {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
time.Sleep(delay)
|
||||||
|
}
|
||||||
|
return nil, lastErr
|
||||||
|
}
|
||||||
|
|
||||||
|
// waitTencentRequestSlot 将进程内所有腾讯云请求统一限速在每秒 4 次以内。 // Logan.Li
|
||||||
|
func waitTencentRequestSlot() {
|
||||||
|
tencentLimiter.Lock()
|
||||||
|
defer tencentLimiter.Unlock()
|
||||||
|
if delay := time.Until(tencentLimiter.next); delay > 0 {
|
||||||
|
time.Sleep(delay)
|
||||||
|
}
|
||||||
|
tencentLimiter.next = time.Now().Add(tencentRequestInterval)
|
||||||
|
}
|
||||||
|
|
||||||
|
// tencentRetryDelay 对 RequestLimitExceeded 使用指数退避,其他错误立即返回。 // Logan.Li
|
||||||
|
func tencentRetryDelay(err error, attempt int) (time.Duration, bool) {
|
||||||
|
var apiErr *tencentAPIError
|
||||||
|
if !errors.As(err, &apiErr) || !strings.HasPrefix(apiErr.Code, "RequestLimitExceeded") || attempt >= tencentMaxAttempts-1 {
|
||||||
|
return 0, false
|
||||||
|
}
|
||||||
|
return (500 * time.Millisecond) * time.Duration(1<<attempt), true
|
||||||
|
}
|
||||||
|
|
||||||
|
// callServiceOnce 执行一次腾讯云 TC3-HMAC-SHA256 签名请求。 // Logan.Li
|
||||||
|
func (p *TencentProvider) callServiceOnce(service, host, version, action, region string, payload interface{}) ([]byte, error) {
|
||||||
body, err := json.Marshal(payload)
|
body, err := json.Marshal(payload)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("编码腾讯云请求失败: %w", err)
|
return nil, fmt.Errorf("编码腾讯云请求失败: %w", err)
|
||||||
@@ -95,6 +155,9 @@ func (p *TencentProvider) callService(service, host, version, action, region str
|
|||||||
return nil, fmt.Errorf("读取腾讯云响应失败: %w", err)
|
return nil, fmt.Errorf("读取腾讯云响应失败: %w", err)
|
||||||
}
|
}
|
||||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||||
|
if resp.StatusCode == http.StatusTooManyRequests {
|
||||||
|
return nil, &tencentAPIError{Code: "RequestLimitExceeded.HTTP429", Message: string(data)}
|
||||||
|
}
|
||||||
return nil, fmt.Errorf("腾讯云 API 返回 HTTP %d: %s", resp.StatusCode, string(data))
|
return nil, fmt.Errorf("腾讯云 API 返回 HTTP %d: %s", resp.StatusCode, string(data))
|
||||||
}
|
}
|
||||||
var envelope struct {
|
var envelope struct {
|
||||||
@@ -106,7 +169,7 @@ func (p *TencentProvider) callService(service, host, version, action, region str
|
|||||||
} `json:"Response"`
|
} `json:"Response"`
|
||||||
}
|
}
|
||||||
if json.Unmarshal(data, &envelope) == nil && envelope.Response.Error != nil {
|
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 nil, &tencentAPIError{Code: envelope.Response.Error.Code, Message: envelope.Response.Error.Message}
|
||||||
}
|
}
|
||||||
return data, nil
|
return data, nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 ""
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
package cloud
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestTencentRetryDelay 验证限频错误指数退避与最大重试次数。 // Logan.Li
|
||||||
|
func TestTencentRetryDelay(t *testing.T) {
|
||||||
|
rateLimitErr := &tencentAPIError{Code: "RequestLimitExceeded", Message: "frequency limit"}
|
||||||
|
delay, retry := tencentRetryDelay(rateLimitErr, 0)
|
||||||
|
if !retry || delay != 500*time.Millisecond {
|
||||||
|
t.Fatalf("首次限频退避错误: retry=%v delay=%s", retry, delay)
|
||||||
|
}
|
||||||
|
|
||||||
|
delay, retry = tencentRetryDelay(rateLimitErr, 2)
|
||||||
|
if !retry || delay != 2*time.Second {
|
||||||
|
t.Fatalf("第三次限频退避错误: retry=%v delay=%s", retry, delay)
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, retry = tencentRetryDelay(rateLimitErr, tencentMaxAttempts-1); retry {
|
||||||
|
t.Fatal("达到最大次数后不应继续重试")
|
||||||
|
}
|
||||||
|
if _, retry = tencentRetryDelay(errors.New("鉴权失败"), 0); retry {
|
||||||
|
t.Fatal("非限频错误不应重试")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,160 @@
|
|||||||
|
package controller
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"math"
|
||||||
|
"net/http"
|
||||||
|
"sort"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"ezops/model"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
)
|
||||||
|
|
||||||
|
// billingMonthCost 表示月度费用汇总。 // Logan.Li
|
||||||
|
type billingMonthCost struct {
|
||||||
|
Month string `json:"month"`
|
||||||
|
Cost float64 `json:"cost"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// billingAttribution 表示可复核的费用变化归因。 // Logan.Li
|
||||||
|
type billingAttribution struct {
|
||||||
|
Dimension string `json:"dimension"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
BaselineCost float64 `json:"baseline_cost"`
|
||||||
|
CurrentCost float64 `json:"current_cost"`
|
||||||
|
Delta float64 `json:"delta"`
|
||||||
|
Contribution float64 `json:"contribution"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetBillingCostAnalysis 返回月度趋势和同期费用归因,AI 只负责解释确定性结果。 // Logan.Li
|
||||||
|
func (h *BillingHandler) GetBillingCostAnalysis(c *gin.Context) {
|
||||||
|
now := time.Now()
|
||||||
|
currentStart := time.Date(now.Year(), now.Month(), 1, 0, 0, 0, 0, now.Location())
|
||||||
|
baselineStart := currentStart.AddDate(0, -1, 0)
|
||||||
|
baselineEndDay := now.Day()
|
||||||
|
baselineLastDay := baselineStart.AddDate(0, 1, -1).Day()
|
||||||
|
if baselineEndDay > baselineLastDay {
|
||||||
|
baselineEndDay = baselineLastDay
|
||||||
|
}
|
||||||
|
baselineEnd := time.Date(baselineStart.Year(), baselineStart.Month(), baselineEndDay, 23, 59, 59, 0, now.Location())
|
||||||
|
analysisStart := currentStart.AddDate(0, -5, 0)
|
||||||
|
currency := c.DefaultQuery("currency", "CNY")
|
||||||
|
|
||||||
|
query := h.db.Model(&model.CloudBilling{}).Where("billing_date >= ? AND currency = ?", analysisStart.Format("2006-01-02"), currency)
|
||||||
|
type monthRow struct {
|
||||||
|
Month string
|
||||||
|
Cost float64
|
||||||
|
}
|
||||||
|
var monthRows []monthRow
|
||||||
|
query.Select("SUBSTR(billing_date, 1, 7) AS month, COALESCE(SUM(cost), 0) AS cost").
|
||||||
|
Group("SUBSTR(billing_date, 1, 7)").Order("month ASC").Scan(&monthRows)
|
||||||
|
months := make([]billingMonthCost, 0, len(monthRows))
|
||||||
|
for _, row := range monthRows {
|
||||||
|
months = append(months, billingMonthCost{Month: row.Month, Cost: roundBilling(row.Cost)})
|
||||||
|
}
|
||||||
|
|
||||||
|
baselineStartDate := baselineStart.Format("2006-01-02")
|
||||||
|
baselineEndDate := baselineEnd.Format("2006-01-02")
|
||||||
|
currentStartDate := currentStart.Format("2006-01-02")
|
||||||
|
currentEndDate := now.Format("2006-01-02")
|
||||||
|
baselineCost := h.sumBillingPeriod(baselineStartDate, baselineEndDate, currency)
|
||||||
|
currentCost := h.sumBillingPeriod(currentStartDate, currentEndDate, currency)
|
||||||
|
totalDelta := currentCost - baselineCost
|
||||||
|
|
||||||
|
attributions := make([]billingAttribution, 0, 24)
|
||||||
|
for _, dimension := range []string{"provider", "account_name", "service", "region"} {
|
||||||
|
attributions = append(attributions, h.billingDimensionAttribution(
|
||||||
|
dimension, baselineStartDate, baselineEndDate, currentStartDate, currentEndDate, currency, totalDelta,
|
||||||
|
)...)
|
||||||
|
}
|
||||||
|
sort.Slice(attributions, func(i, j int) bool {
|
||||||
|
return math.Abs(attributions[i].Delta) > math.Abs(attributions[j].Delta)
|
||||||
|
})
|
||||||
|
if len(attributions) > 20 {
|
||||||
|
attributions = attributions[:20]
|
||||||
|
}
|
||||||
|
|
||||||
|
var dataThrough string
|
||||||
|
h.db.Model(&model.CloudBilling{}).Where("currency = ?", currency).Select("COALESCE(MAX(billing_date), '')").Scan(&dataThrough)
|
||||||
|
c.JSON(http.StatusOK, model.Response{Code: 200, Message: "success", Data: gin.H{
|
||||||
|
"analysis_start": analysisStart.Format("2006-01-02"),
|
||||||
|
"analysis_end": currentEndDate,
|
||||||
|
"data_through": dataThrough,
|
||||||
|
"currency": currency,
|
||||||
|
"incomplete_period": true,
|
||||||
|
"months": months,
|
||||||
|
"baseline_period": fmt.Sprintf("%s - %s", baselineStartDate, baselineEndDate),
|
||||||
|
"comparison_period": fmt.Sprintf("%s - %s", currentStartDate, currentEndDate),
|
||||||
|
"baseline_cost": roundBilling(baselineCost),
|
||||||
|
"comparison_cost": roundBilling(currentCost),
|
||||||
|
"total_delta": roundBilling(totalDelta),
|
||||||
|
"attributions": attributions,
|
||||||
|
"contribution_formula": "(当前期费用 - 基准期费用) / (当前期总费用 - 基准期总费用)",
|
||||||
|
}})
|
||||||
|
}
|
||||||
|
|
||||||
|
// sumBillingPeriod 汇总指定币种和日期范围费用。 // Logan.Li
|
||||||
|
func (h *BillingHandler) sumBillingPeriod(startDate, endDate, currency string) float64 {
|
||||||
|
var total float64
|
||||||
|
h.db.Model(&model.CloudBilling{}).
|
||||||
|
Where("billing_date BETWEEN ? AND ? AND currency = ?", startDate, endDate, currency).
|
||||||
|
Select("COALESCE(SUM(cost), 0)").Scan(&total)
|
||||||
|
return total
|
||||||
|
}
|
||||||
|
|
||||||
|
// billingDimensionAttribution 计算单一维度的同期费用变化贡献。 // Logan.Li
|
||||||
|
func (h *BillingHandler) billingDimensionAttribution(dimension, baselineStart, baselineEnd, currentStart, currentEnd, currency string, totalDelta float64) []billingAttribution {
|
||||||
|
type dimensionRow struct {
|
||||||
|
Name string
|
||||||
|
Cost float64
|
||||||
|
}
|
||||||
|
load := func(startDate, endDate string) []dimensionRow {
|
||||||
|
var rows []dimensionRow
|
||||||
|
h.db.Model(&model.CloudBilling{}).
|
||||||
|
Select(dimension+" AS name, COALESCE(SUM(cost), 0) AS cost").
|
||||||
|
Where("billing_date BETWEEN ? AND ? AND currency = ?", startDate, endDate, currency).
|
||||||
|
Group(dimension).Scan(&rows)
|
||||||
|
return rows
|
||||||
|
}
|
||||||
|
baseline := map[string]float64{}
|
||||||
|
current := map[string]float64{}
|
||||||
|
for _, row := range load(baselineStart, baselineEnd) {
|
||||||
|
baseline[row.Name] = row.Cost
|
||||||
|
}
|
||||||
|
for _, row := range load(currentStart, currentEnd) {
|
||||||
|
current[row.Name] = row.Cost
|
||||||
|
}
|
||||||
|
for name := range baseline {
|
||||||
|
if _, ok := current[name]; !ok {
|
||||||
|
current[name] = 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
result := make([]billingAttribution, 0, len(current))
|
||||||
|
for rawName, currentCost := range current {
|
||||||
|
name := rawName
|
||||||
|
if rawName == "" {
|
||||||
|
name = "未分类"
|
||||||
|
}
|
||||||
|
baselineCost := baseline[rawName]
|
||||||
|
delta := currentCost - baselineCost
|
||||||
|
if math.Abs(delta) < 0.01 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
contribution := 0.0
|
||||||
|
if math.Abs(totalDelta) >= 0.01 {
|
||||||
|
contribution = delta / totalDelta * 100
|
||||||
|
}
|
||||||
|
result = append(result, billingAttribution{
|
||||||
|
Dimension: dimension, Name: name, BaselineCost: roundBilling(baselineCost),
|
||||||
|
CurrentCost: roundBilling(currentCost), Delta: roundBilling(delta), Contribution: roundBilling(contribution),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
// roundBilling 统一费用和百分比精度。 // Logan.Li
|
||||||
|
func roundBilling(value float64) float64 {
|
||||||
|
return math.Round(value*100) / 100
|
||||||
|
}
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
package controller
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"ezops/model"
|
||||||
|
|
||||||
|
"github.com/glebarez/sqlite"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestBillingDimensionAttribution 验证费用变化额和贡献度使用数据库完整数据计算。 // Logan.Li
|
||||||
|
func TestBillingDimensionAttribution(t *testing.T) {
|
||||||
|
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("打开测试数据库失败: %v", err)
|
||||||
|
}
|
||||||
|
if err = db.AutoMigrate(&model.CloudBilling{}); err != nil {
|
||||||
|
t.Fatalf("迁移账单表失败: %v", err)
|
||||||
|
}
|
||||||
|
records := []model.CloudBilling{
|
||||||
|
{BillingDate: "2026-06-01", Provider: "tencent", Service: "CVM", Region: "广州", AccountName: "预发", Currency: "CNY", Cost: 100},
|
||||||
|
{BillingDate: "2026-07-01", Provider: "tencent", Service: "CVM", Region: "广州", AccountName: "预发", Currency: "CNY", Cost: 180},
|
||||||
|
{BillingDate: "2026-07-01", Provider: "tencent", Service: "COS", Region: "广州", AccountName: "预发", Currency: "CNY", Cost: 20},
|
||||||
|
}
|
||||||
|
if err = db.Create(&records).Error; err != nil {
|
||||||
|
t.Fatalf("写入测试账单失败: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
handler := NewBillingHandler(db)
|
||||||
|
items := handler.billingDimensionAttribution("service", "2026-06-01", "2026-06-30", "2026-07-01", "2026-07-31", "CNY", 100)
|
||||||
|
if len(items) != 2 {
|
||||||
|
t.Fatalf("归因项数量错误: got=%d want=2", len(items))
|
||||||
|
}
|
||||||
|
result := map[string]billingAttribution{}
|
||||||
|
for _, item := range items {
|
||||||
|
result[item.Name] = item
|
||||||
|
}
|
||||||
|
if result["CVM"].Delta != 80 || result["CVM"].Contribution != 80 {
|
||||||
|
t.Fatalf("CVM 归因错误: %+v", result["CVM"])
|
||||||
|
}
|
||||||
|
if result["COS"].Delta != 20 || result["COS"].Contribution != 20 {
|
||||||
|
t.Fatalf("COS 归因错误: %+v", result["COS"])
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
}
|
}
|
||||||
@@ -323,6 +323,7 @@ func Register(r *gin.Engine, db *gorm.DB, log *zap.Logger, cfg *config.Config, c
|
|||||||
billings.POST("/batch", billingHandler.BatchCreateBillings)
|
billings.POST("/batch", billingHandler.BatchCreateBillings)
|
||||||
billings.DELETE("/:id", billingHandler.DeleteBilling)
|
billings.DELETE("/:id", billingHandler.DeleteBilling)
|
||||||
billings.GET("/stats", billingHandler.GetBillingStats)
|
billings.GET("/stats", billingHandler.GetBillingStats)
|
||||||
|
billings.GET("/cost-analysis", billingHandler.GetBillingCostAnalysis)
|
||||||
billings.GET("/providers", billingHandler.GetProviderOptions)
|
billings.GET("/providers", billingHandler.GetProviderOptions)
|
||||||
billings.POST("/sync", billingHandler.SyncYesterdayBillings)
|
billings.POST("/sync", billingHandler.SyncYesterdayBillings)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,9 @@ export const billingApi = {
|
|||||||
getStats: async () => {
|
getStats: async () => {
|
||||||
return http.get('/cloud-billings/stats');
|
return http.get('/cloud-billings/stats');
|
||||||
},
|
},
|
||||||
|
getCostAnalysis: async () => {
|
||||||
|
return http.get('/cloud-billings/cost-analysis');
|
||||||
|
},
|
||||||
list: async (params?: { provider?: string; cloud_account_id?: number; start_date?: string; end_date?: string; page?: number; page_size?: number }) => {
|
list: async (params?: { provider?: string; cloud_account_id?: number; start_date?: string; end_date?: string; page?: number; page_size?: number }) => {
|
||||||
return http.get<ListResponse>('/cloud-billings', params);
|
return http.get<ListResponse>('/cloud-billings', params);
|
||||||
},
|
},
|
||||||
@@ -47,4 +50,4 @@ export const billingApi = {
|
|||||||
toggleAlertRule: async (id: number | string) => {
|
toggleAlertRule: async (id: number | string) => {
|
||||||
return http.post(`/billing-alerts/${id}/toggle`);
|
return http.post(`/billing-alerts/${id}/toggle`);
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
@@ -54,6 +54,7 @@ export function AIPredictiveAnalytics() {
|
|||||||
const [billingStats, setBillingStats] = useState<JsonData | null>(null);
|
const [billingStats, setBillingStats] = useState<JsonData | null>(null);
|
||||||
const [billingRecords, setBillingRecords] = useState<JsonData[]>([]);
|
const [billingRecords, setBillingRecords] = useState<JsonData[]>([]);
|
||||||
const [billingAlertRules, setBillingAlertRules] = useState<JsonData[]>([]);
|
const [billingAlertRules, setBillingAlertRules] = useState<JsonData[]>([]);
|
||||||
|
const [costAnalysis, setCostAnalysis] = useState<JsonData | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
void loadData();
|
void loadData();
|
||||||
@@ -66,12 +67,13 @@ export function AIPredictiveAnalytics() {
|
|||||||
start.setDate(start.getDate() - 89);
|
start.setDate(start.getDate() - 89);
|
||||||
const startDate = start.toISOString().slice(0, 10);
|
const startDate = start.toISOString().slice(0, 10);
|
||||||
|
|
||||||
const [metricsData, statsResp, billStats, billList, billRules] = await Promise.all([
|
const [metricsData, statsResp, billStats, billList, billRules, analysis] = await Promise.all([
|
||||||
dashboardStatsApi.getMetrics(72),
|
dashboardStatsApi.getMetrics(72),
|
||||||
serverApi.getServerStats(),
|
serverApi.getServerStats(),
|
||||||
billingApi.getStats().catch(() => null),
|
billingApi.getStats().catch(() => null),
|
||||||
billingApi.list({ start_date: startDate, page_size: 500 }).catch(() => ({ list: [] })),
|
billingApi.list({ start_date: startDate, page_size: 500 }).catch(() => ({ list: [] })),
|
||||||
billingApi.listAlertRules().catch(() => ({ list: [] })),
|
billingApi.listAlertRules().catch(() => ({ list: [] })),
|
||||||
|
billingApi.getCostAnalysis().catch(() => null),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
setRawMetrics(Array.isArray(metricsData) ? metricsData : []);
|
setRawMetrics(Array.isArray(metricsData) ? metricsData : []);
|
||||||
@@ -79,6 +81,7 @@ export function AIPredictiveAnalytics() {
|
|||||||
setBillingStats(billStats);
|
setBillingStats(billStats);
|
||||||
setBillingRecords((billList as JsonData)?.list || []);
|
setBillingRecords((billList as JsonData)?.list || []);
|
||||||
setBillingAlertRules((billRules as JsonData)?.list || []);
|
setBillingAlertRules((billRules as JsonData)?.list || []);
|
||||||
|
setCostAnalysis(analysis);
|
||||||
} catch {
|
} catch {
|
||||||
// 保持空数据,组件显示空状态
|
// 保持空数据,组件显示空状态
|
||||||
} finally {
|
} finally {
|
||||||
@@ -104,8 +107,9 @@ export function AIPredictiveAnalytics() {
|
|||||||
billingStats,
|
billingStats,
|
||||||
billingRecords,
|
billingRecords,
|
||||||
billingAlertRules,
|
billingAlertRules,
|
||||||
|
costAnalysis,
|
||||||
}),
|
}),
|
||||||
[billingStats, billingRecords, billingAlertRules],
|
[billingStats, billingRecords, billingAlertRules, costAnalysis],
|
||||||
);
|
);
|
||||||
|
|
||||||
const getTrendIcon = (trend: string) => {
|
const getTrendIcon = (trend: string) => {
|
||||||
@@ -259,4 +263,4 @@ export function AIPredictiveAnalytics() {
|
|||||||
</Tabs>
|
</Tabs>
|
||||||
</PageShell>
|
</PageShell>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -38,7 +38,7 @@ export function PredictiveCostTab({
|
|||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const [insight, setInsight] = useState('');
|
const [insight, setInsight] = useState('');
|
||||||
const [analyzing, setAnalyzing] = useState(false);
|
const [analyzing, setAnalyzing] = useState(false);
|
||||||
const { summary, dimensionForecasts, budgetRisks, billingPattern, recentDailyAvg } = costData;
|
const { summary, dimensionForecasts, budgetRisks, billingPattern, recentDailyAvg, costAnalysis } = costData;
|
||||||
const hasData = summary.monthCost > 0 || summary.monthTrend.length > 0;
|
const hasData = summary.monthCost > 0 || summary.monthTrend.length > 0;
|
||||||
|
|
||||||
const getTrendIcon = (trend: string) => {
|
const getTrendIcon = (trend: string) => {
|
||||||
@@ -129,6 +129,17 @@ export function PredictiveCostTab({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{costAnalysis?.incomplete_period && (
|
||||||
|
<div className="flex items-start gap-2 text-sm dg-text-secondary">
|
||||||
|
<AlertTriangle className="h-4 w-4 dg-text-yellow shrink-0 mt-0.5" />
|
||||||
|
<span>
|
||||||
|
{t('aiops.costPredict.incompletePeriod', {
|
||||||
|
date: costAnalysis.data_through || costAnalysis.analysis_end,
|
||||||
|
})}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
|
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
|
||||||
<div className="p-3 rounded-lg border border-[var(--dg-border)] bg-[var(--dg-bg-tertiary)]/50">
|
<div className="p-3 rounded-lg border border-[var(--dg-border)] bg-[var(--dg-bg-tertiary)]/50">
|
||||||
<p className="text-xs dg-text-muted mb-1">{t('aiops.predictivecost.s7g8h9i0j')}</p>
|
<p className="text-xs dg-text-muted mb-1">{t('aiops.predictivecost.s7g8h9i0j')}</p>
|
||||||
@@ -172,6 +183,58 @@ export function PredictiveCostTab({
|
|||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
|
{!loading && costAnalysis && costAnalysis.attributions.length > 0 && (
|
||||||
|
<Card className="dg-card">
|
||||||
|
<CardHeader className="pb-3">
|
||||||
|
<CardTitle className="text-[var(--dg-text-primary)] text-base">
|
||||||
|
{t('aiops.costPredict.costAttribution')}
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3 text-sm">
|
||||||
|
<div>
|
||||||
|
<p className="text-xs dg-text-muted">{t('aiops.costPredict.baselinePeriod')}</p>
|
||||||
|
<p className="font-semibold text-[var(--dg-text-primary)]">
|
||||||
|
{formatCurrency(costAnalysis.baseline_cost)}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs dg-text-muted mt-1">{costAnalysis.baseline_period}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-xs dg-text-muted">{t('aiops.costPredict.comparisonPeriod')}</p>
|
||||||
|
<p className="font-semibold text-[var(--dg-text-primary)]">
|
||||||
|
{formatCurrency(costAnalysis.comparison_cost)}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs dg-text-muted mt-1">{costAnalysis.comparison_period}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-xs dg-text-muted">{t('aiops.costPredict.costChange')}</p>
|
||||||
|
<p className={`font-semibold ${costAnalysis.total_delta > 0 ? 'dg-text-red' : 'dg-text-green'}`}>
|
||||||
|
{costAnalysis.total_delta > 0 ? '+' : ''}{formatCurrency(costAnalysis.total_delta)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
{costAnalysis.attributions.slice(0, 6).map((item) => (
|
||||||
|
<div key={`${item.dimension}-${item.name}`} className="flex items-center justify-between gap-3 text-sm py-1">
|
||||||
|
<div className="min-w-0">
|
||||||
|
<span className="dg-text-muted mr-2">
|
||||||
|
{t(`aiops.costPredict.dimension.${item.dimension}`, { defaultValue: item.dimension })}
|
||||||
|
</span>
|
||||||
|
<span className="text-[var(--dg-text-primary)] break-words">{item.name}</span>
|
||||||
|
</div>
|
||||||
|
<div className="shrink-0 text-right">
|
||||||
|
<span className={item.delta > 0 ? 'dg-text-red' : 'dg-text-green'}>
|
||||||
|
{item.delta > 0 ? '+' : ''}{formatCurrency(item.delta)}
|
||||||
|
</span>
|
||||||
|
<span className="dg-text-muted ml-2">{item.contribution.toFixed(1)}%</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
{!loading && dimensionForecasts.length > 0 && (
|
{!loading && dimensionForecasts.length > 0 && (
|
||||||
<Card className="dg-card">
|
<Card className="dg-card">
|
||||||
<CardHeader className="pb-3">
|
<CardHeader className="pb-3">
|
||||||
@@ -345,4 +408,4 @@ export function PredictiveCostTab({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -8,6 +8,7 @@ import type {
|
|||||||
CostPredictionSummary,
|
CostPredictionSummary,
|
||||||
ComputedCostPredictions,
|
ComputedCostPredictions,
|
||||||
ComputeCostPredictionsInput,
|
ComputeCostPredictionsInput,
|
||||||
|
CostAnalysis,
|
||||||
} from './predict-types';
|
} from './predict-types';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -166,9 +167,18 @@ function forecastMonthEnd(
|
|||||||
const dailyAvg = costs.length > 0 ? costs.reduce((a, b) => a + b, 0) / costs.length : 0;
|
const dailyAvg = costs.length > 0 ? costs.reduce((a, b) => a + b, 0) / costs.length : 0;
|
||||||
const recentDailyAvg = recentRunRate(series);
|
const recentDailyAvg = recentRunRate(series);
|
||||||
const billingPattern = detectBillingPattern(series);
|
const billingPattern = detectBillingPattern(series);
|
||||||
// 预付费/前高后平:用近 7 日运行费率外推,避免线性回归高估或矛盾 // Logan.Li
|
// 稳态账单逐日累加回归预测值,不能把斜率本身误当成每日费用。 // Logan.Li
|
||||||
const extrapolation =
|
const intercept = dailyAvg - slope * ((costs.length - 1) / 2);
|
||||||
billingPattern === 'front_loaded' ? recentDailyAvg * daysLeft : slope * daysLeft;
|
const maxDaily = Math.max(dailyAvg, recentDailyAvg, ...costs, 1) * 3;
|
||||||
|
let extrapolation = 0;
|
||||||
|
if (billingPattern === 'front_loaded') {
|
||||||
|
extrapolation = recentDailyAvg * daysLeft;
|
||||||
|
} else {
|
||||||
|
for (let day = 0; day < daysLeft; day += 1) {
|
||||||
|
const projectedDaily = intercept + slope * (costs.length + day);
|
||||||
|
extrapolation += Math.max(0, Math.min(maxDaily, projectedDaily));
|
||||||
|
}
|
||||||
|
}
|
||||||
const predictedMonthEnd = Math.max(0, Math.round((currentMonth + extrapolation) * 100) / 100);
|
const predictedMonthEnd = Math.max(0, Math.round((currentMonth + extrapolation) * 100) / 100);
|
||||||
let confidence = Math.round(55 + r2 * 40);
|
let confidence = Math.round(55 + r2 * 40);
|
||||||
if (billingPattern === 'front_loaded') confidence = Math.max(45, confidence - 15);
|
if (billingPattern === 'front_loaded') confidence = Math.max(45, confidence - 15);
|
||||||
@@ -192,7 +202,7 @@ function forecastMonthEnd(
|
|||||||
* // Logan.Li
|
* // Logan.Li
|
||||||
*/
|
*/
|
||||||
export function computeCostPredictions(input: ComputeCostPredictionsInput): ComputedCostPredictions {
|
export function computeCostPredictions(input: ComputeCostPredictionsInput): ComputedCostPredictions {
|
||||||
const { billingStats, billingRecords, billingAlertRules } = input;
|
const { billingStats, billingRecords, billingAlertRules, costAnalysis } = input;
|
||||||
const empty: ComputedCostPredictions = {
|
const empty: ComputedCostPredictions = {
|
||||||
summary: {
|
summary: {
|
||||||
monthCost: 0,
|
monthCost: 0,
|
||||||
@@ -212,6 +222,7 @@ export function computeCostPredictions(input: ComputeCostPredictionsInput): Comp
|
|||||||
recentDailyAvg: 0,
|
recentDailyAvg: 0,
|
||||||
billingPattern: 'sparse',
|
billingPattern: 'sparse',
|
||||||
topServices: [],
|
topServices: [],
|
||||||
|
costAnalysis: null,
|
||||||
};
|
};
|
||||||
|
|
||||||
if (!billingStats) return empty;
|
if (!billingStats) return empty;
|
||||||
@@ -373,6 +384,7 @@ export function computeCostPredictions(input: ComputeCostPredictionsInput): Comp
|
|||||||
recentDailyAvg: overall.recentDailyAvg,
|
recentDailyAvg: overall.recentDailyAvg,
|
||||||
billingPattern: overall.billingPattern,
|
billingPattern: overall.billingPattern,
|
||||||
topServices: topServicesByCost(billingRecords),
|
topServices: topServicesByCost(billingRecords),
|
||||||
|
costAnalysis: costAnalysis ? (costAnalysis as unknown as CostAnalysis) : null,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -381,7 +393,7 @@ export function computeCostPredictions(input: ComputeCostPredictionsInput): Comp
|
|||||||
* // Logan.Li
|
* // Logan.Li
|
||||||
*/
|
*/
|
||||||
export function buildCostInsightPrompt(data: ComputedCostPredictions): string {
|
export function buildCostInsightPrompt(data: ComputedCostPredictions): string {
|
||||||
const { summary, dimensionForecasts, budgetRisks, recentDailyAvg, billingPattern, topServices } = data;
|
const { summary, dimensionForecasts, budgetRisks, recentDailyAvg, billingPattern, topServices, costAnalysis } = data;
|
||||||
if (summary.monthCost <= 0 && dimensionForecasts.length === 0) return '';
|
if (summary.monthCost <= 0 && dimensionForecasts.length === 0) return '';
|
||||||
|
|
||||||
const patternHint =
|
const patternHint =
|
||||||
@@ -418,6 +430,16 @@ export function buildCostInsightPrompt(data: ComputedCostPredictions): string {
|
|||||||
topServices.length > 0
|
topServices.length > 0
|
||||||
? topServices.map((s) => i18n.t('aiops.costPredict.serviceLine', { service: s.service, cost: s.cost.toFixed(2) }))
|
? topServices.map((s) => i18n.t('aiops.costPredict.serviceLine', { service: s.service, cost: s.cost.toFixed(2) }))
|
||||||
: [`- ${i18n.t('aiops.costPredict.noServiceDetail')}`];
|
: [`- ${i18n.t('aiops.costPredict.noServiceDetail')}`];
|
||||||
|
const attributionLines = costAnalysis?.attributions?.length
|
||||||
|
? costAnalysis.attributions.slice(0, 8).map((item) =>
|
||||||
|
i18n.t('aiops.costPredict.attributionLine', {
|
||||||
|
dimension: item.dimension,
|
||||||
|
name: item.name,
|
||||||
|
delta: item.delta.toFixed(2),
|
||||||
|
contribution: item.contribution.toFixed(2),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
: [`- ${i18n.t('aiops.costPredict.noAttribution')}`];
|
||||||
|
|
||||||
return [
|
return [
|
||||||
i18n.t('aiops.costPredict.promptIntro'),
|
i18n.t('aiops.costPredict.promptIntro'),
|
||||||
@@ -427,10 +449,16 @@ export function buildCostInsightPrompt(data: ComputedCostPredictions): string {
|
|||||||
i18n.t('aiops.costPredict.predictedEnd', { amount: summary.predictedMonthEnd.toFixed(2), confidence: summary.confidence }),
|
i18n.t('aiops.costPredict.predictedEnd', { amount: summary.predictedMonthEnd.toFixed(2), confidence: summary.confidence }),
|
||||||
i18n.t('aiops.costPredict.dailySummary', { dailyAvg: summary.dailyAvg.toFixed(2), recentAvg: recentDailyAvg.toFixed(2), daysRemaining: summary.daysRemaining }),
|
i18n.t('aiops.costPredict.dailySummary', { dailyAvg: summary.dailyAvg.toFixed(2), recentAvg: recentDailyAvg.toFixed(2), daysRemaining: summary.daysRemaining }),
|
||||||
i18n.t('aiops.costPredict.billingPatternLabel', { hint: patternHint }),
|
i18n.t('aiops.costPredict.billingPatternLabel', { hint: patternHint }),
|
||||||
|
costAnalysis?.incomplete_period
|
||||||
|
? i18n.t('aiops.costPredict.incompletePrompt', { date: costAnalysis.data_through })
|
||||||
|
: '',
|
||||||
'',
|
'',
|
||||||
i18n.t('aiops.costPredict.sectionTopServices'),
|
i18n.t('aiops.costPredict.sectionTopServices'),
|
||||||
...serviceLines,
|
...serviceLines,
|
||||||
'',
|
'',
|
||||||
|
i18n.t('aiops.costPredict.sectionAttribution'),
|
||||||
|
...attributionLines,
|
||||||
|
'',
|
||||||
i18n.t('aiops.costPredict.sectionDimensionForecast'),
|
i18n.t('aiops.costPredict.sectionDimensionForecast'),
|
||||||
...dimLines,
|
...dimLines,
|
||||||
'',
|
'',
|
||||||
|
|||||||
@@ -77,6 +77,31 @@ export interface CostPredictionSummary {
|
|||||||
providerDist: { provider: string; cost: number; percent: number }[];
|
providerDist: { provider: string; cost: number; percent: number }[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 后端确定性费用归因结果。 // Logan.Li */
|
||||||
|
export interface CostAttribution {
|
||||||
|
dimension: string;
|
||||||
|
name: string;
|
||||||
|
baseline_cost: number;
|
||||||
|
current_cost: number;
|
||||||
|
delta: number;
|
||||||
|
contribution: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 同期成本分析,供页面展示并作为 AI 事实输入。 // Logan.Li */
|
||||||
|
export interface CostAnalysis {
|
||||||
|
analysis_start: string;
|
||||||
|
analysis_end: string;
|
||||||
|
data_through: string;
|
||||||
|
currency: string;
|
||||||
|
incomplete_period: boolean;
|
||||||
|
baseline_period: string;
|
||||||
|
comparison_period: string;
|
||||||
|
baseline_cost: number;
|
||||||
|
comparison_cost: number;
|
||||||
|
total_delta: number;
|
||||||
|
attributions: CostAttribution[];
|
||||||
|
}
|
||||||
|
|
||||||
export interface ComputedCostPredictions {
|
export interface ComputedCostPredictions {
|
||||||
summary: CostPredictionSummary;
|
summary: CostPredictionSummary;
|
||||||
dimensionForecasts: CostDimensionForecast[];
|
dimensionForecasts: CostDimensionForecast[];
|
||||||
@@ -88,6 +113,8 @@ export interface ComputedCostPredictions {
|
|||||||
billingPattern: 'steady' | 'front_loaded' | 'sparse';
|
billingPattern: 'steady' | 'front_loaded' | 'sparse';
|
||||||
/** 本月 Top 服务消费 */
|
/** 本月 Top 服务消费 */
|
||||||
topServices: { service: string; cost: number }[];
|
topServices: { service: string; cost: number }[];
|
||||||
|
/** 后端按完整数据集计算的同期归因。 // Logan.Li */
|
||||||
|
costAnalysis: CostAnalysis | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============ 容量预测入参/出参 ============
|
// ============ 容量预测入参/出参 ============
|
||||||
@@ -114,6 +141,7 @@ export interface ComputeCostPredictionsInput {
|
|||||||
billingStats: JsonData | null;
|
billingStats: JsonData | null;
|
||||||
billingRecords: JsonData[];
|
billingRecords: JsonData[];
|
||||||
billingAlertRules: JsonData[];
|
billingAlertRules: JsonData[];
|
||||||
|
costAnalysis?: JsonData | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============ 共享常量 ============
|
// ============ 共享常量 ============
|
||||||
|
|||||||
@@ -8795,6 +8795,19 @@
|
|||||||
"costPredict.recRising": "Cost trending up, monitor new service spending",
|
"costPredict.recRising": "Cost trending up, monitor new service spending",
|
||||||
"costPredict.recDeclining": "Cost declining, optimization is effective",
|
"costPredict.recDeclining": "Cost declining, optimization is effective",
|
||||||
"costPredict.recStable": "Cost stable, maintain current strategy"
|
"costPredict.recStable": "Cost stable, maintain current strategy"
|
||||||
|
,"costPredict.incompletePeriod": "The current billing period is incomplete. Data is available through {{date}} and the forecast will change as billing continues"
|
||||||
|
,"costPredict.incompletePrompt": "- Billing note: current period is incomplete; data through {{date}}"
|
||||||
|
,"costPredict.costAttribution": "Cost Change Attribution"
|
||||||
|
,"costPredict.baselinePeriod": "Baseline"
|
||||||
|
,"costPredict.comparisonPeriod": "Comparison"
|
||||||
|
,"costPredict.costChange": "Cost change"
|
||||||
|
,"costPredict.sectionAttribution": "[Cost Change Attribution]"
|
||||||
|
,"costPredict.noAttribution": "No comparable cost change attribution is available"
|
||||||
|
,"costPredict.attributionLine": "- {{dimension}} / {{name}}: change {{delta}} CNY, contribution {{contribution}}%"
|
||||||
|
,"costPredict.dimension.provider": "Provider"
|
||||||
|
,"costPredict.dimension.account_name": "Account"
|
||||||
|
,"costPredict.dimension.service": "Service"
|
||||||
|
,"costPredict.dimension.region": "Region"
|
||||||
},
|
},
|
||||||
"breadcrumb": {
|
"breadcrumb": {
|
||||||
"aiAlertAnalyzer": "Alert Analysis",
|
"aiAlertAnalyzer": "Alert Analysis",
|
||||||
|
|||||||
@@ -8795,6 +8795,19 @@
|
|||||||
"costPredict.recRising": "费用呈上升趋势,建议关注新增服务消费",
|
"costPredict.recRising": "费用呈上升趋势,建议关注新增服务消费",
|
||||||
"costPredict.recDeclining": "费用持续下降,成本优化有效",
|
"costPredict.recDeclining": "费用持续下降,成本优化有效",
|
||||||
"costPredict.recStable": "费用平稳,保持当前成本策略"
|
"costPredict.recStable": "费用平稳,保持当前成本策略"
|
||||||
|
,"costPredict.incompletePeriod": "当前账期尚未结束,费用数据截至 {{date}},预测结果会随出账继续变化"
|
||||||
|
,"costPredict.incompletePrompt": "- 账期说明:当前账期未结束,数据截至 {{date}}"
|
||||||
|
,"costPredict.costAttribution": "费用变化归因"
|
||||||
|
,"costPredict.baselinePeriod": "基准期"
|
||||||
|
,"costPredict.comparisonPeriod": "对比期"
|
||||||
|
,"costPredict.costChange": "费用变化"
|
||||||
|
,"costPredict.sectionAttribution": "【费用变化归因】"
|
||||||
|
,"costPredict.noAttribution": "暂无可归因的同期费用变化"
|
||||||
|
,"costPredict.attributionLine": "- {{dimension}} / {{name}}:变化 {{delta}} 元,贡献 {{contribution}}%"
|
||||||
|
,"costPredict.dimension.provider": "云厂商"
|
||||||
|
,"costPredict.dimension.account_name": "账号"
|
||||||
|
,"costPredict.dimension.service": "服务"
|
||||||
|
,"costPredict.dimension.region": "区域"
|
||||||
},
|
},
|
||||||
"breadcrumb": {
|
"breadcrumb": {
|
||||||
"aiAlertAnalyzer": "告警分析",
|
"aiAlertAnalyzer": "告警分析",
|
||||||
|
|||||||
Reference in new issue
Block a user