Files
aiops/backend/pkg/billingsync/sync.go
T
Logan.Li 65e2f09b97 fix: 按业务时区同步昨日账单
- 固定按 Asia/Shanghai 计算账单昨日日期

- 补充 UTC 服务器时区回归测试
2026-07-29 05:57:07 +08:00

154 lines
4.2 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 云账单同步引擎 — 从云账号 AK 拉取日账单写入本地 // Logan.Li
package billingsync
import (
"fmt"
"time"
"ezops/cloud"
"ezops/model"
"gorm.io/gorm"
)
// Result 同步结果
type Result struct {
BillingDate string `json:"billing_date"`
Accounts int `json:"accounts"`
Created int `json:"created"`
Updated int `json:"updated"`
Skipped int `json:"skipped"`
Errors []string `json:"errors"`
}
// Yesterday 昨日日期 YYYY-MM-DD
func Yesterday() string {
return billingYesterdayAt(time.Now())
}
// billingYesterdayAt 按平台业务时区计算昨日,避免服务器 UTC 时区造成日期偏差。 // Logan.Li
func billingYesterdayAt(now time.Time) string {
location, err := time.LoadLocation("Asia/Shanghai")
if err != nil {
location = time.FixedZone("CST", 8*60*60)
}
return now.In(location).AddDate(0, 0, -1).Format("2006-01-02")
}
// SyncDate 同步指定日期账单(全部或单个活跃云账号)
func SyncDate(db *gorm.DB, billingDate string, accountID *uint) Result {
result := Result{
BillingDate: billingDate,
Errors: []string{},
}
var accounts []model.CloudAccount
q := db.Where("status = ?", "active")
if accountID != nil {
q = q.Where("id = ?", *accountID)
} else {
// 批量同步只选择已接入账单 API 的厂商,避免无效失败。 // Logan.Li
q = q.Where("provider IN ?", cloud.SupportedBillingProviders)
}
q.Find(&accounts)
result.Accounts = len(accounts)
if len(accounts) == 0 {
result.Errors = append(result.Errors, "没有可同步的活跃云账号")
return result
}
for _, account := range accounts {
created, updated, skipped, err := syncAccount(db, account, billingDate)
result.Created += created
result.Updated += updated
result.Skipped += skipped
if err != nil {
result.Errors = append(result.Errors, fmt.Sprintf("%s(%s): %s", account.Name, account.Provider, err.Error()))
}
}
return result
}
// SummaryMessage 生成执行摘要文案
func SummaryMessage(r Result) string {
msg := fmt.Sprintf("%s 新增 %d 条,更新 %d 条", r.BillingDate, r.Created, r.Updated)
if len(r.Errors) > 0 {
msg += fmt.Sprintf(";%d 个账号异常", len(r.Errors))
}
return msg
}
func syncAccount(db *gorm.DB, account model.CloudAccount, billingDate string) (created, updated, skipped int, err error) {
fetcher := cloud.NewBillingFetcher(account.Provider, account.AccessKey, account.SecretKey)
if fetcher == nil {
return 0, 0, 0, fmt.Errorf("暂不支持该云厂商账单同步")
}
records, err := fetcher.FetchDailyBill(billingDate)
if err != nil {
return 0, 0, 0, err
}
if len(records) == 0 {
return 0, 0, 1, fmt.Errorf("该日期暂无账单数据(可能尚未出账)")
}
accountID := account.ID
accountName := account.Name
for _, rec := range records {
var existing model.CloudBilling
findErr := db.Where(
"cloud_account_id = ? AND billing_date = ? AND provider = ? AND service = ? AND region = ?",
accountID, billingDate, rec.Provider, rec.Service, rec.Region,
).Take(&existing).Error
if findErr == gorm.ErrRecordNotFound {
row := model.CloudBilling{
CloudAccountID: &accountID,
AccountName: accountName,
Provider: rec.Provider,
BillingDate: billingDate,
Cost: rec.Cost,
Currency: rec.Currency,
Service: rec.Service,
Region: rec.Region,
Description: rec.Description,
}
if row.Currency == "" {
row.Currency = "CNY"
}
if db.Create(&row).Error == nil {
created++
} else {
skipped++
}
continue
}
if findErr != nil {
skipped++
continue
}
if db.Model(&existing).Updates(map[string]interface{}{
"cost": rec.Cost,
"currency": rec.Currency,
"description": rec.Description,
"account_name": accountName,
}).Error == nil {
updated++
} else {
skipped++
}
}
monthStart := billingDate[:7] + "-01"
var monthCost float64
db.Model(&model.CloudBilling{}).
Where("cloud_account_id = ? AND billing_date >= ?", accountID, monthStart).
Select("COALESCE(SUM(cost), 0)").Scan(&monthCost)
db.Model(&account).Update("monthly_cost", monthCost)
return created, updated, skipped, nil
}