// 云账单同步引擎 — 从云账号 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 billingCurrency := records[0].Currency if billingCurrency == "" { billingCurrency = "CNY" } for _, rec := range records { if rec.Currency == "" { rec.Currency = billingCurrency } 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 >= ? AND currency = ?", accountID, monthStart, billingCurrency). Select("COALESCE(SUM(cost), 0)").Scan(&monthCost) db.Model(&account).Updates(map[string]interface{}{ "monthly_cost": monthCost, "billing_currency": billingCurrency, }) return created, updated, skipped, nil }