package services import ( "log" "sync" "time" "mengyaping-backend/config" "mengyaping-backend/models" "mengyaping-backend/storage" "mengyaping-backend/utils" ) // MonitorService 监控服务 type MonitorService struct { httpClient *utils.HTTPClient storage *storage.Storage stopCh chan struct{} rescheduleCh chan struct{} running bool mu sync.Mutex } var ( monitorService *MonitorService monitorOnce sync.Once ) // GetMonitorService 获取监控服务单例 func GetMonitorService() *MonitorService { monitorOnce.Do(func() { st := storage.GetStorage() cfg := config.GetConfig() monitorService = &MonitorService{ httpClient: utils.NewHTTPClient(cfg.Monitor.Timeout), storage: st, stopCh: make(chan struct{}), rescheduleCh: make(chan struct{}, 1), } }) return monitorService } // Start 启动监控服务 func (s *MonitorService) Start() { s.mu.Lock() if s.running { s.mu.Unlock() return } s.running = true s.mu.Unlock() log.Println("监控服务已启动") go s.runLoop() } // Reschedule 在检测间隔配置变更后唤醒主循环以使用新周期(非阻塞) func (s *MonitorService) Reschedule() { select { case s.rescheduleCh <- struct{}{}: default: } } func (s *MonitorService) runLoop() { go s.checkAll() d := config.GetConfig().Monitor.Interval if d <= 0 { d = 30 * time.Minute } timer := time.NewTimer(d) defer timer.Stop() for { select { case <-s.stopCh: if !timer.Stop() { select { case <-timer.C: default: } } log.Println("监控服务已停止") return case <-s.rescheduleCh: if !timer.Stop() { select { case <-timer.C: default: } } d = config.GetConfig().Monitor.Interval if d <= 0 { d = 30 * time.Minute } timer.Reset(d) case <-timer.C: go s.checkAll() d = config.GetConfig().Monitor.Interval if d <= 0 { d = 30 * time.Minute } timer.Reset(d) } } } // Stop 停止监控服务 func (s *MonitorService) Stop() { s.mu.Lock() defer s.mu.Unlock() if s.running { close(s.stopCh) s.running = false } } // checkAll 检查所有网站(错峰执行,避免并发暴涨) func (s *MonitorService) checkAll() { websites := s.storage.GetWebsites() semaphore := make(chan struct{}, 3) // 最多 3 个并发检测 for i, website := range websites { // 每个网站之间间隔 1 秒,把检测分散开 if i > 0 { time.Sleep(1 * time.Second) } var wg sync.WaitGroup for _, urlInfo := range website.URLs { wg.Add(1) go func(w models.Website, u models.URLInfo) { defer wg.Done() semaphore <- struct{}{} defer func() { <-semaphore }() s.checkURL(w, u) }(website, urlInfo) } wg.Wait() } // 检测完毕后,逐个解析 DNS s.resolveAllWebsiteIPs(websites) // 保存记录 s.storage.SaveAll() log.Printf("本轮检测完成,共 %d 个网站", len(websites)) } // resolveAllWebsiteIPs 逐个解析所有网站域名 IP(每次都刷新) func (s *MonitorService) resolveAllWebsiteIPs(websites []models.Website) { for i, website := range websites { if len(website.URLs) == 0 { continue } if i > 0 { time.Sleep(500 * time.Millisecond) } s.resolveWebsiteIP(website) } } // resolveWebsiteIP 解析单个网站的域名 IP func (s *MonitorService) resolveWebsiteIP(website models.Website) { if len(website.URLs) == 0 { return } if utils.IsIntranetURL(website.URLs[0].URL) { w := s.storage.GetWebsite(website.ID) if w != nil && len(w.IPAddresses) > 0 { w.IPAddresses = nil w.UpdatedAt = time.Now() s.storage.UpdateWebsite(*w) } return } ips, err := utils.ResolveDomainIPs(website.URLs[0].URL) if err != nil { log.Printf("DNS解析失败 [%s]: %v", website.Name, err) return } if len(ips) == 0 { return } w := s.storage.GetWebsite(website.ID) if w == nil { return } w.IPAddresses = ips w.UpdatedAt = time.Now() s.storage.UpdateWebsite(*w) log.Printf("DNS解析 [%s] → %v", website.Name, ips) } // checkURL 检查单个URL(带重试) func (s *MonitorService) checkURL(website models.Website, urlInfo models.URLInfo) { cfg := config.GetConfig() maxRetries := cfg.Monitor.RetryCount var result utils.CheckResult for attempt := 0; attempt <= maxRetries; attempt++ { if attempt > 0 { time.Sleep(time.Duration(attempt) * 2 * time.Second) log.Printf("重试 [%s] %s - 第 %d 次重试", website.Name, urlInfo.URL, attempt) } result = s.httpClient.CheckWebsiteStatus(urlInfo.URL) if result.Error == nil && utils.IsSuccessStatus(result.StatusCode) { break } } record := models.MonitorRecord{ WebsiteID: website.ID, URLID: urlInfo.ID, URL: urlInfo.URL, StatusCode: result.StatusCode, Latency: result.Latency.Milliseconds(), IsUp: result.Error == nil && utils.IsSuccessStatus(result.StatusCode), CheckedAt: time.Now(), } if result.Error != nil { record.Error = result.Error.Error() } s.storage.AddRecord(record) // 仅当网站无标题时才做完整检测来获取元数据 if record.IsUp && website.Title == "" { fullResult := s.httpClient.CheckWebsite(urlInfo.URL) if fullResult.Title != "" { w := s.storage.GetWebsite(website.ID) if w != nil { w.Title = fullResult.Title w.UpdatedAt = time.Now() s.storage.UpdateWebsite(*w) } } } log.Printf("检测 [%s] %s - 状态码: %d, 延迟: %dms, 可用: %v", website.Name, urlInfo.URL, result.StatusCode, result.Latency.Milliseconds(), record.IsUp) } // CheckWebsiteNow 立即检查指定网站(状态 + DNS,等待完成后保存) func (s *MonitorService) CheckWebsiteNow(websiteID string) { website := s.storage.GetWebsite(websiteID) if website == nil { return } // 逐个检测该网站的所有 URL for _, urlInfo := range website.URLs { s.checkURL(*website, urlInfo) } // 刷新 DNS s.resolveWebsiteIP(*website) s.storage.SaveAll() } // GetWebsiteStatus 获取网站状态 func (s *MonitorService) GetWebsiteStatus(websiteID string) *models.WebsiteStatus { website := s.storage.GetWebsite(websiteID) if website == nil { return nil } isIntranet := len(website.URLs) > 0 && utils.IsIntranetURL(website.URLs[0].URL) status := &models.WebsiteStatus{ Website: *website, IsIntranet: isIntranet, URLStatuses: []models.URLStatus{}, } now := time.Now() since24h := now.Add(-24 * time.Hour) since7d := now.Add(-7 * 24 * time.Hour) since30d := now.Add(-30 * 24 * time.Hour) var totalUptime24h, totalUptime7d float64 var urlCount int for _, urlInfo := range website.URLs { urlStatus := s.getURLStatus(website.ID, urlInfo, since24h, since7d) status.URLStatuses = append(status.URLStatuses, urlStatus) totalUptime24h += urlStatus.Uptime24h totalUptime7d += urlStatus.Uptime7d urlCount++ } if urlCount > 0 { status.Uptime24h = totalUptime24h / float64(urlCount) status.Uptime7d = totalUptime7d / float64(urlCount) } status.DailyHistory = s.storage.GetWebsiteDailyAggregates(website.ID, since30d) if pt, ut := s.storage.GetWebsiteDayProbeTotals(website.ID, since30d); pt > 0 { status.Uptime30d = float64(ut) / float64(pt) * 100 } // 获取最后检测时间 for _, urlStatus := range status.URLStatuses { if urlStatus.CurrentState.CheckedAt.After(status.LastChecked) { status.LastChecked = urlStatus.CurrentState.CheckedAt } } return status } // getURLStatus 获取URL状态:每个 URL 仅 1 次读小时表 + 1 次读 latest(在内存拆 24h/7d) func (s *MonitorService) getURLStatus(websiteID string, urlInfo models.URLInfo, since24h, since7d time.Time) models.URLStatus { var latest *models.MonitorRecord if rec := s.storage.GetLatestRecord(websiteID, urlInfo.ID); rec != nil { latest = rec } hRows := s.storage.GetProbeHourRowsForURL(websiteID, urlInfo.ID, since7d) return s.buildURLStatusFromHours(websiteID, urlInfo, since24h, since7d, latest, hRows) } func hourlyStatFromProbeRow(h storage.MonitorProbeHour) models.HourlyStats { st := models.HourlyStats{ Hour: h.HourAt, TotalCount: h.ProbeCount, UpCount: h.UpCount, } if h.ProbeCount > 0 { st.AvgLatency = h.LatencySum / int64(h.ProbeCount) st.Uptime = float64(h.UpCount) / float64(h.ProbeCount) * 100 } return st } func hourWindowsFromRows(rows []storage.MonitorProbeHour, since24h, since7d time.Time) ( stats24h []models.HourlyStats, stats7d []models.HourlyStats, t24, u24, l24, t7, u7 int64, ) { for _, h := range rows { if !h.HourAt.Before(since7d) { t7 += int64(h.ProbeCount) u7 += int64(h.UpCount) stats7d = append(stats7d, hourlyStatFromProbeRow(h)) } if !h.HourAt.Before(since24h) { t24 += int64(h.ProbeCount) u24 += int64(h.UpCount) l24 += h.LatencySum stats24h = append(stats24h, hourlyStatFromProbeRow(h)) } } return stats24h, stats7d, t24, u24, l24, t7, u7 } func (s *MonitorService) buildURLStatusFromHours( websiteID string, urlInfo models.URLInfo, since24h, since7d time.Time, latest *models.MonitorRecord, hRows []storage.MonitorProbeHour, ) models.URLStatus { urlStatus := models.URLStatus{URLInfo: urlInfo} if latest != nil { urlStatus.CurrentState = *latest } s24, s7, t24, u24, l24, t7, u7 := hourWindowsFromRows(hRows, since24h, since7d) urlStatus.History24h = hourlyStatsToMonitorRecords(websiteID, urlInfo.ID, s24) if t24 > 0 { urlStatus.Uptime24h = float64(u24) / float64(t24) * 100 urlStatus.AvgLatency = l24 / t24 } urlStatus.History7d = s7 if t7 > 0 { urlStatus.Uptime7d = float64(u7) / float64(t7) * 100 } return urlStatus } func hourlyStatsToMonitorRecords(websiteID, urlID string, stats []models.HourlyStats) []models.MonitorRecord { out := make([]models.MonitorRecord, 0, len(stats)) for _, h := range stats { isUp := h.TotalCount > 0 && h.UpCount*2 >= h.TotalCount out = append(out, models.MonitorRecord{ WebsiteID: websiteID, URLID: urlID, Latency: h.AvgLatency, IsUp: isUp, CheckedAt: h.Hour, }) } return out } // GetAllWebsiteStatuses 获取所有网站状态(批量查 probe 表,避免每个站点/URL 多次 SQL) func (s *MonitorService) GetAllWebsiteStatuses() []models.WebsiteStatus { websites := s.storage.GetWebsites() if len(websites) == 0 { return nil } now := time.Now() since24h := now.Add(-24 * time.Hour) since7d := now.Add(-7 * 24 * time.Hour) since30d := now.Add(-30 * 24 * time.Hour) since30dDay := time.Date(since30d.Year(), since30d.Month(), since30d.Day(), 0, 0, 0, 0, since30d.Location()) latestMap := s.storage.GetProbeLatestKeyMap() hourMap := s.storage.GroupProbeHoursSince(since7d) dailyBySite, uptime30dBySite := s.storage.LoadAllWebsiteDayRollups(since30dDay) statuses := make([]models.WebsiteStatus, 0, len(websites)) for _, w := range websites { daily := dailyBySite[w.ID] u30 := uptime30dBySite[w.ID] st := s.buildWebsiteStatusBulk(w, since24h, since7d, latestMap, hourMap, daily, u30) if st != nil { statuses = append(statuses, *st) } } return statuses } func (s *MonitorService) buildWebsiteStatusBulk( w models.Website, since24h, since7d time.Time, latestMap map[string]models.MonitorRecord, hourMap map[string]map[string][]storage.MonitorProbeHour, daily []models.DailyStats, uptime30d float64, ) *models.WebsiteStatus { isIntranet := len(w.URLs) > 0 && utils.IsIntranetURL(w.URLs[0].URL) status := &models.WebsiteStatus{ Website: w, IsIntranet: isIntranet, URLStatuses: []models.URLStatus{}, } siteHours := hourMap[w.ID] if siteHours == nil { siteHours = map[string][]storage.MonitorProbeHour{} } var totalUptime24h, totalUptime7d float64 urlCount := 0 for _, urlInfo := range w.URLs { key := w.ID + "\x00" + urlInfo.ID var latest *models.MonitorRecord if rec, ok := latestMap[key]; ok { r := rec latest = &r } hRows := siteHours[urlInfo.ID] urlSt := s.buildURLStatusFromHours(w.ID, urlInfo, since24h, since7d, latest, hRows) status.URLStatuses = append(status.URLStatuses, urlSt) totalUptime24h += urlSt.Uptime24h totalUptime7d += urlSt.Uptime7d urlCount++ } if urlCount > 0 { status.Uptime24h = totalUptime24h / float64(urlCount) status.Uptime7d = totalUptime7d / float64(urlCount) } if daily != nil { status.DailyHistory = daily } else { status.DailyHistory = []models.DailyStats{} } status.Uptime30d = uptime30d for _, urlSt := range status.URLStatuses { if urlSt.CurrentState.CheckedAt.After(status.LastChecked) { status.LastChecked = urlSt.CurrentState.CheckedAt } } return status }