Files
mengyaping/mengyaping-backend/services/monitor.go

489 lines
12 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.
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
}