package service import ( "context" "fmt" "sort" "time" "oci-portal/internal/model" ) // OverviewTenants 是租户数量与类别分布。 type OverviewTenants struct { Total int `json:"total"` Alive int `json:"alive"` Dead int `json:"dead"` ByType map[string]int `json:"byType"` } // OverviewCheck 是测活快照聚合;实例数为各配置默认区域之和, // CoveredConfigs/TotalConfigs 用于前端标注覆盖度。 type OverviewCheck struct { HasActiveTask bool `json:"hasActiveTask"` CoveredConfigs int `json:"coveredConfigs"` TotalConfigs int `json:"totalConfigs"` InstanceCount int `json:"instanceCount"` LastCheckedAt *time.Time `json:"lastCheckedAt"` } // OverviewCostDay 是一天的跨租户成本合计。 type OverviewCostDay struct { Day string `json:"day"` Amount float64 `json:"amount"` } // OverviewCost 是近 7 日成本快照聚合;同币种金额跨配置相加,跨币种拆 Series。 type OverviewCost struct { HasActiveTask bool `json:"hasActiveTask"` CoveredConfigs int `json:"coveredConfigs"` Currency string `json:"currency"` Total float64 `json:"total"` Days []OverviewCostDay `json:"days"` // Series 按币种拆分的序列(合计降序);顶层 Currency/Total/Days 恒为首个 // (主)币种,多币种租户的其余币种只出现在 Series,不与主币种相加。 Series []OverviewCostSeries `json:"series"` } // OverviewCostSeries 是单一币种的成本序列;跨币种金额不可直接相加。 type OverviewCostSeries struct { Currency string `json:"currency"` Total float64 `json:"total"` Days []OverviewCostDay `json:"days"` } // OverviewTasks 是后台任务数量统计。 type OverviewTasks struct { Total int `json:"total"` Active int `json:"active"` HealthCheck int `json:"healthCheck"` Cost int `json:"cost"` Snatch int `json:"snatch"` } // Overview 是总览页聚合数据。 type Overview struct { Tenants OverviewTenants `json:"tenants"` Check OverviewCheck `json:"check"` Cost OverviewCost `json:"cost"` Tasks OverviewTasks `json:"tasks"` } // Overview 聚合总览数据:租户分布、测活/成本快照与任务统计,全部读本地库不发云端请求。 func (s *OciConfigService) Overview(ctx context.Context) (*Overview, error) { var out Overview if err := s.overviewTenants(ctx, &out); err != nil { return nil, err } if err := s.overviewCheck(ctx, &out); err != nil { return nil, err } if err := s.overviewCost(ctx, &out); err != nil { return nil, err } if err := s.overviewTasks(ctx, &out); err != nil { return nil, err } return &out, nil } func (s *OciConfigService) overviewTenants(ctx context.Context, out *Overview) error { configs, err := s.List(ctx) if err != nil { return err } out.Tenants.Total = len(configs) out.Tenants.ByType = map[string]int{} for _, cfg := range configs { out.Tenants.ByType[cfg.AccountType]++ switch cfg.AliveStatus { case model.AliveStatusAlive: out.Tenants.Alive++ case model.AliveStatusDead, model.AliveStatusSuspended: out.Tenants.Dead++ } } return nil } // hasActiveTask 判断是否存在指定类型且调度中的任务。 func (s *OciConfigService) hasActiveTask(ctx context.Context, taskType string) (bool, error) { var n int64 err := s.db.WithContext(ctx).Model(&model.Task{}). Where("type = ? AND status = ?", taskType, model.TaskStatusActive).Count(&n).Error if err != nil { return false, fmt.Errorf("count %s tasks: %w", taskType, err) } return n > 0, nil } func (s *OciConfigService) overviewCheck(ctx context.Context, out *Overview) error { has, err := s.hasActiveTask(ctx, model.TaskTypeHealthCheck) if err != nil { return err } out.Check.HasActiveTask = has out.Check.TotalConfigs = out.Tenants.Total var snaps []model.CheckSnapshot err = s.db.WithContext(ctx). Where("oci_config_id IN (?)", s.db.Model(&model.OciConfig{}).Select("id")). Find(&snaps).Error if err != nil { return fmt.Errorf("load check snapshots: %w", err) } out.Check.CoveredConfigs = len(snaps) for _, snap := range snaps { out.Check.InstanceCount += snap.InstanceCount if out.Check.LastCheckedAt == nil || snap.CheckedAt.After(*out.Check.LastCheckedAt) { t := snap.CheckedAt out.Check.LastCheckedAt = &t } } return nil } func (s *OciConfigService) overviewCost(ctx context.Context, out *Overview) error { has, err := s.hasActiveTask(ctx, model.TaskTypeCost) if err != nil { return err } out.Cost.HasActiveTask = has since := time.Now().UTC().AddDate(0, 0, -7).Format("2006-01-02") var snaps []model.CostSnapshot err = s.db.WithContext(ctx). Where("day >= ? AND oci_config_id IN (?)", since, s.db.Model(&model.OciConfig{}).Select("id")). Order("day").Find(&snaps).Error if err != nil { return fmt.Errorf("load cost snapshots: %w", err) } aggregateCostDays(&out.Cost, snaps) return nil } // aggregateCostDays 把逐配置逐日的快照按币种聚合(snaps 须按 day 升序)。 // 不同币种金额不可直接相加:按币种拆序列,顶层 Currency/Total/Days 取合计最大的 // 主币种,其余币种只出现在 Series 里。 func aggregateCostDays(cost *OverviewCost, snaps []model.CostSnapshot) { perCur := map[string]map[string]float64{} dayOrder := map[string][]string{} covered := map[uint]bool{} for _, snap := range snaps { byDay := perCur[snap.Currency] if byDay == nil { byDay = map[string]float64{} perCur[snap.Currency] = byDay } if _, ok := byDay[snap.Day]; !ok { dayOrder[snap.Currency] = append(dayOrder[snap.Currency], snap.Day) } byDay[snap.Day] += snap.Amount covered[snap.OciConfigID] = true } cost.CoveredConfigs = len(covered) cost.Days = []OverviewCostDay{} cost.Series = costSeries(perCur, dayOrder) if len(cost.Series) > 0 { cost.Currency, cost.Total, cost.Days = cost.Series[0].Currency, cost.Series[0].Total, cost.Series[0].Days } } // costSeries 组装各币种序列并按合计降序(相同合计按币种名,保证稳定输出)。 func costSeries(perCur map[string]map[string]float64, dayOrder map[string][]string) []OverviewCostSeries { series := make([]OverviewCostSeries, 0, len(perCur)) for currency, byDay := range perCur { item := OverviewCostSeries{Currency: currency, Days: make([]OverviewCostDay, 0, len(byDay))} for _, day := range dayOrder[currency] { item.Days = append(item.Days, OverviewCostDay{Day: day, Amount: byDay[day]}) item.Total += byDay[day] } series = append(series, item) } sort.Slice(series, func(i, j int) bool { if series[i].Total != series[j].Total { return series[i].Total > series[j].Total } return series[i].Currency < series[j].Currency }) return series } func (s *OciConfigService) overviewTasks(ctx context.Context, out *Overview) error { var tasks []model.Task if err := s.db.WithContext(ctx).Find(&tasks).Error; err != nil { return fmt.Errorf("list tasks: %w", err) } out.Tasks.Total = len(tasks) for _, t := range tasks { if t.Status == model.TaskStatusActive { out.Tasks.Active++ } switch t.Type { case model.TaskTypeHealthCheck: out.Tasks.HealthCheck++ case model.TaskTypeCost: out.Tasks.Cost++ case model.TaskTypeSnatch: out.Tasks.Snatch++ } } return nil }