diff --git a/backend/controller/alert_engine_monitor.go b/backend/controller/alert_engine_monitor.go index de1b4d2..69c814a 100644 --- a/backend/controller/alert_engine_monitor.go +++ b/backend/controller/alert_engine_monitor.go @@ -162,6 +162,20 @@ func (e *AlertEngine) evalSiteRule(rule model.MonitorAlertRule, task model.SiteT if triggered { msg = fmt.Sprintf("拨测 %s HTTP 状态码异常", task.Name) } + case "content_changed": + // 内容监控:服务可达但返回数据变了,与"拨测失败"是完全不同的问题。 // Logan.Li + if !siteprobe.ContentCheckEnabled(task.ContentCheckMode) { + return + } + var pending int64 + e.db.Model(&model.SiteContentChange{}). + Where("task_id = ? AND status = ?", task.ID, model.ContentChangePending). + Count(&pending) + triggered = pending > 0 + value = float64(pending) + if triggered { + msg = fmt.Sprintf("接口 %s 返回内容已变更,待确认 %d 项", task.Name, pending) + } case "ssl_expiry_days": if !siteprobe.IsSSLProbeTask(task) { return @@ -318,6 +332,16 @@ func (e *AlertEngine) siteRuleStillTriggered(rule model.MonitorAlertRule, task m return e.compareOperator(rule.Operator, task.AvgResponse, rule.Threshold) case "availability": return e.compareOperator(rule.Operator, task.SuccessRate, rule.Threshold) + case "content_changed": + // 仍有待确认变更 → 告警持续;人工接受/忽略后自动恢复。 // Logan.Li + if !siteprobe.ContentCheckEnabled(task.ContentCheckMode) { + return false + } + var pending int64 + e.db.Model(&model.SiteContentChange{}). + Where("task_id = ? AND status = ?", task.ID, model.ContentChangePending). + Count(&pending) + return pending > 0 case "ssl_expiry_days": if !siteprobe.IsSSLProbeTask(task) { return false diff --git a/backend/controller/site.go b/backend/controller/site.go index 635f265..b6172ae 100644 --- a/backend/controller/site.go +++ b/backend/controller/site.go @@ -128,6 +128,58 @@ func (h *SiteHandler) UpdateTask(c *gin.Context) { if v, ok := raw["expected_status_code"].(float64); ok { updates["expected_status"] = int(v) } + + // 内容监控配置。归一化规则一旦改动,旧基线就失去意义, + // 必须重置基线让下一轮自动重建,否则会立刻产生一条"变更"。 // Logan.Li + contentConfigChanged := false + if v, ok := raw["content_check_mode"].(string); ok { + mode := normalizeContentMode(v) + if mode != task.ContentCheckMode { + contentConfigChanged = true + if mode == "off" { + // 关闭内容监控时同时清空基线状态,避免残留待确认变更。 + updates["current_baseline_id"] = 0 + updates["pending_change_count"] = 0 + } + } + updates["content_check_mode"] = mode + } + if v, ok := raw["content_paths"].(string); ok { + if v != task.ContentPaths { + contentConfigChanged = true + } + updates["content_paths"] = v + } + if v, ok := raw["content_ignore_rules"].(string); ok { + if v != task.ContentIgnoreRules { + contentConfigChanged = true + } + updates["content_ignore_rules"] = v + } + if v, ok := raw["content_min_length"].(float64); ok { + if int(v) != task.ContentMinLength { + contentConfigChanged = true + } + updates["content_min_length"] = int(v) + } + if v, ok := raw["content_confirm_times"].(float64); ok { + n := int(v) + if n <= 0 { + n = 2 + } + updates["content_confirm_times"] = n + } + if v, ok := raw["content_sort_lines"].(bool); ok { + if v != task.ContentSortLines { + contentConfigChanged = true + } + updates["content_sort_lines"] = v + } + if contentConfigChanged { + updates["current_baseline_id"] = 0 + updates["pending_change_count"] = 0 + } + if v, ok := raw["enabled"].(bool); ok { if v { updates["status"] = model.SiteTaskStatusRunning @@ -151,6 +203,13 @@ func (h *SiteHandler) deleteSiteTaskCascade(taskID uint) error { if err := tx.Where("task_id = ?", taskID).Delete(&model.SiteAlert{}).Error; err != nil { return err } + // 内容监控的子表一并清理,避免任务删除后残留孤立基线/变更。 // Logan.Li + if err := tx.Where("task_id = ?", taskID).Delete(&model.SiteContentChange{}).Error; err != nil { + return err + } + if err := tx.Where("task_id = ?", taskID).Delete(&model.SiteBaseline{}).Error; err != nil { + return err + } tx.Where("category = ? AND target_id = ?", model.MonitorAlertSite, taskID).Delete(&model.MonitorAlertRule{}) return tx.Delete(&model.SiteTask{}, taskID).Error }) diff --git a/backend/controller/site_content.go b/backend/controller/site_content.go new file mode 100644 index 0000000..d6fe722 --- /dev/null +++ b/backend/controller/site_content.go @@ -0,0 +1,149 @@ +package controller + +import ( + "net/http" + "strconv" + + "ezops/middleware" + "ezops/model" + "ezops/pkg/siteprobe" + + "github.com/gin-gonic/gin" +) + +// GetTaskContent 返回某任务的内容监控视图:当前基线 + 待确认变更 + 结构化 diff。 +// +// 前端详情页用这一个接口即可渲染"基线 / 待确认 / 并排对比"三块。 // Logan.Li +// GET /api/v1/site/tasks/:id/content +func (h *SiteHandler) GetTaskContent(c *gin.Context) { + taskID, err := strconv.ParseUint(c.Param("id"), 10, 64) + if err != nil || taskID == 0 { + c.JSON(http.StatusBadRequest, model.Response{Code: 400, Message: "任务 ID 无效"}) + return + } + + var task model.SiteTask + if err := h.db.First(&task, taskID).Error; err != nil { + c.JSON(http.StatusNotFound, model.Response{Code: 404, Message: "任务不存在"}) + return + } + + var baseline model.SiteBaseline + hasBaseline := false + if task.CurrentBaselineID > 0 { + if err := h.db.First(&baseline, task.CurrentBaselineID).Error; err == nil { + hasBaseline = true + } + } + + var pending model.SiteContentChange + hasPending := false + if err := h.db.Where("task_id = ? AND status = ?", task.ID, model.ContentChangePending). + Order("id DESC").Take(&pending).Error; err == nil { + hasPending = true + } + + var baselineCount int64 + h.db.Model(&model.SiteBaseline{}).Where("task_id = ?", task.ID).Count(&baselineCount) + var pendingCount int64 + h.db.Model(&model.SiteContentChange{}). + Where("task_id = ? AND status = ?", task.ID, model.ContentChangePending).Count(&pendingCount) + + data := gin.H{ + "task_id": task.ID, + "enabled": siteprobe.ContentCheckEnabled(task.ContentCheckMode), + "mode": task.ContentCheckMode, + "content_paths": task.ContentPaths, + "content_ignore": task.ContentIgnoreRules, + "min_length": task.ContentMinLength, + "confirm_times": task.ContentConfirmTimes, + "sort_lines": task.ContentSortLines, + "baseline_count": baselineCount, + "pending_change_count": pendingCount, + "has_baseline": hasBaseline, + "has_pending": hasPending, + } + if hasBaseline { + data["baseline"] = baseline + } + if hasPending { + data["pending"] = pending + } + c.JSON(http.StatusOK, model.Response{Code: 200, Message: "success", Data: data}) +} + +// ListBaselines 内容基线版本历史。 // Logan.Li +// GET /api/v1/site/tasks/:id/baselines +func (h *SiteHandler) ListBaselines(c *gin.Context) { + taskID, err := strconv.ParseUint(c.Param("id"), 10, 64) + if err != nil || taskID == 0 { + c.JSON(http.StatusBadRequest, model.Response{Code: 400, Message: "任务 ID 无效"}) + return + } + var items []model.SiteBaseline + h.db.Where("task_id = ?", taskID).Order("version DESC").Find(&items) + if items == nil { + items = []model.SiteBaseline{} + } + c.JSON(http.StatusOK, model.Response{Code: 200, Message: "success", Data: items}) +} + +// ListContentChanges 内容变更记录(支持 status 过滤)。 // Logan.Li +// GET /api/v1/site/tasks/:id/changes +func (h *SiteHandler) ListContentChanges(c *gin.Context) { + taskID, err := strconv.ParseUint(c.Param("id"), 10, 64) + if err != nil || taskID == 0 { + c.JSON(http.StatusBadRequest, model.Response{Code: 400, Message: "任务 ID 无效"}) + return + } + q := h.db.Where("task_id = ?", taskID) + if status := c.Query("status"); status != "" { + q = q.Where("status = ?", status) + } + var items []model.SiteContentChange + q.Order("id DESC").Limit(100).Find(&items) + if items == nil { + items = []model.SiteContentChange{} + } + c.JSON(http.StatusOK, model.Response{Code: 200, Message: "success", Data: items}) +} + +// AcceptContentChange 接受某次变更作为新基线。 +// +// 必须人工确认:真实上游变更与"我方采集配置错误"需要人来判断, +// 全自动接受会让告警彻底失去意义。 // Logan.Li +// POST /api/v1/site/changes/:id/accept +func (h *SiteHandler) AcceptContentChange(c *gin.Context) { + id, err := strconv.ParseUint(c.Param("id"), 10, 64) + if err != nil || id == 0 { + c.JSON(http.StatusBadRequest, model.Response{Code: 400, Message: "变更 ID 无效"}) + return + } + var req struct { + Note string `json:"note"` + } + _ = c.ShouldBindJSON(&req) + + user := middleware.GetUsername(c) + if err := siteprobe.AcceptChange(h.db, uint(id), user, req.Note); err != nil { + c.JSON(http.StatusBadRequest, model.Response{Code: 400, Message: err.Error()}) + return + } + c.JSON(http.StatusOK, model.Response{Code: 200, Message: "已接受为新基线"}) +} + +// IgnoreContentChange 将变更标记为误报忽略(不更新基线)。 // Logan.Li +// POST /api/v1/site/changes/:id/ignore +func (h *SiteHandler) IgnoreContentChange(c *gin.Context) { + id, err := strconv.ParseUint(c.Param("id"), 10, 64) + if err != nil || id == 0 { + c.JSON(http.StatusBadRequest, model.Response{Code: 400, Message: "变更 ID 无效"}) + return + } + user := middleware.GetUsername(c) + if err := siteprobe.IgnoreChange(h.db, uint(id), user); err != nil { + c.JSON(http.StatusBadRequest, model.Response{Code: 400, Message: err.Error()}) + return + } + c.JSON(http.StatusOK, model.Response{Code: 200, Message: "已忽略该变更"}) +} diff --git a/backend/controller/site_view.go b/backend/controller/site_view.go index 62aa50c..47a2a49 100644 --- a/backend/controller/site_view.go +++ b/backend/controller/site_view.go @@ -219,5 +219,29 @@ func parseCreateTaskRequest(raw map[string]interface{}) model.SiteTask { if v, ok := raw["dns_resolver"].(string); ok { task.DNSResolver = strings.TrimSpace(v) } + // 内容监控配置 // Logan.Li + task.ContentCheckMode = normalizeContentMode(getStr("content_check_mode")) + task.ContentPaths = getStr("content_paths") + task.ContentIgnoreRules = getStr("content_ignore_rules") + task.ContentMinLength = getInt(0, "content_min_length") + task.ContentConfirmTimes = getInt(2, "content_confirm_times") + if task.ContentConfirmTimes <= 0 { + task.ContentConfirmTimes = 2 + } + if v, ok := raw["content_sort_lines"].(bool); ok { + task.ContentSortLines = v + } return task } + +// normalizeContentMode 把前端传来的模式归一化,非法值一律回退为 off。 // Logan.Li +func normalizeContentMode(mode string) string { + switch strings.ToLower(strings.TrimSpace(mode)) { + case "text": + return siteprobe.ContentModeText + case "json": + return siteprobe.ContentModeJSON + default: + return siteprobe.ContentModeOff + } +} diff --git a/backend/migrations/migrations.go b/backend/migrations/migrations.go index d88dd08..43a802a 100644 --- a/backend/migrations/migrations.go +++ b/backend/migrations/migrations.go @@ -10,6 +10,7 @@ import ( "strings" "time" + "ezops/model" "ezops/pkg/database" "gorm.io/gorm" @@ -91,6 +92,20 @@ func All() []Migration { return executeDialectSQL(db, "0005_agent_settings") }, }, + { + Version: "0006_site_content_monitor", + Description: "站点拨测内容监控(基线 + 变更)", + Apply: func(db *gorm.DB) error { + // 用 AutoMigrate 而非静态 SQL:它是幂等的。 + // 空库上 0000_baseline_schema 已经建好这些列与表,静态 ALTER 会因 + // duplicate column 失败;旧库则正好在这里补齐缺失的列/表。 // Logan.Li + return db.AutoMigrate( + &model.SiteTask{}, + &model.SiteBaseline{}, + &model.SiteContentChange{}, + ) + }, + }, } } diff --git a/backend/model/site.go b/backend/model/site.go index e761bb5..3f24285 100644 --- a/backend/model/site.go +++ b/backend/model/site.go @@ -76,6 +76,24 @@ type SiteTask struct { UpdatedAt time.Time `gorm:"not null;comment:更新时间" json:"updated_at"` DeletedAt *time.Time `gorm:"index;comment:删除时间" json:"deleted_at,omitempty"` + // ── 内容监控(Content Monitor)──────────────────────────────── + // 区别于"可用性拨测":服务活着但返回内容变了,这里负责发现。 // Logan.Li + ContentCheckMode string `gorm:"type:varchar(8);default:'off';comment:内容检查模式 off/text/json" json:"content_check_mode"` + // ContentPaths JSON 模式要参与比对字段的 JSONPath,如 ["$.data.status","$.data.ips"] + ContentPaths string `gorm:"type:text;comment:JSONPath字段列表(JSON数组)" json:"content_paths,omitempty"` + // ContentIgnoreRules 忽略规则:文本按正则替换为空,避免时间戳/nonce 造成误报 + ContentIgnoreRules string `gorm:"type:text;comment:忽略规则(JSON数组,正则)" json:"content_ignore_rules,omitempty"` + // ContentMinLength 小于该长度判为"采集异常"而非"内容变更",防止错误页误报 + ContentMinLength int `gorm:"default:0;comment:最小有效长度(字节)" json:"content_min_length"` + // ContentConfirmTimes 连续 N 次不一致才判定变更,抑制抖动 + ContentConfirmTimes int `gorm:"default:2;comment:连续确认次数" json:"content_confirm_times"` + // ContentSortLines 纯文本是否排序后比对(顺序无关的清单类接口应开启) + ContentSortLines bool `gorm:"default:false;comment:文本排序后比对" json:"content_sort_lines"` + // CurrentBaselineID 当前生效的基线版本 + CurrentBaselineID uint `gorm:"default:0;comment:当前基线ID" json:"current_baseline_id"` + // PendingChangeCount 待确认的变更次数(用于防抖计数) + PendingChangeCount int `gorm:"default:0;comment:连续变更计数" json:"pending_change_count"` + // 关联关系 Results []SiteResult `gorm:"foreignKey:TaskID" json:"results,omitempty"` Alerts []SiteAlert `gorm:"foreignKey:TaskID" json:"alerts,omitempty"` @@ -265,3 +283,55 @@ type SiteResultQuery struct { Limit int `json:"limit" form:"limit"` Offset int `json:"offset" form:"offset"` } + +// ============================================ +// 内容监控:基线版本 + 变更记录 +// ============================================ + +// SiteBaseline 内容基线(一个任务的多版本快照)。 +// +// 首次采集自动建立 v1(source=auto),之后每次"接受新基线"新增一版(source=manual)。 +// 保留完整规范化内容,用于并排 diff 与回溯。 // Logan.Li +type SiteBaseline struct { + ID uint `gorm:"primaryKey;autoIncrement" json:"id"` + TaskID uint `gorm:"not null;index:idx_baseline_task;comment:任务ID" json:"task_id"` + Version int `gorm:"not null;comment:版本号(从1递增)" json:"version"` + ContentHash string `gorm:"type:varchar(64);not null;comment:规范化内容sha256" json:"content_hash"` + ContentText string `gorm:"type:longtext;comment:规范化后的内容快照" json:"content_text,omitempty"` + Source string `gorm:"type:varchar(16);default:'auto';comment:来源 auto/manual" json:"source"` + AcceptedBy string `gorm:"type:varchar(128);comment:确认人" json:"accepted_by"` + Note string `gorm:"type:varchar(512);comment:备注" json:"note"` + CreatedAt time.Time `gorm:"not null;comment:创建时间" json:"created_at"` +} + +// TableName 指定表名 // Logan.Li +func (SiteBaseline) TableName() string { return "monitor_site_baselines" } + +// SiteContentChange 内容变更记录。 +// +// Status 流转:pending(已发现,待处理)→ accepted(接受为新基线)/ ignored(误报忽略)。 +// DiffPayload 存结构化差异,前端并排视图直接渲染。 // Logan.Li +type SiteContentChange struct { + ID uint `gorm:"primaryKey;autoIncrement" json:"id"` + TaskID uint `gorm:"not null;index:idx_change_task" json:"task_id"` + FromBaselineID uint `gorm:"not null;comment:变更前基线" json:"from_baseline_id"` + FromVersion int `gorm:"comment:变更前版本号" json:"from_version"` + ContentHash string `gorm:"type:varchar(64);comment:新内容hash" json:"content_hash"` + ContentText string `gorm:"type:longtext;comment:新内容快照" json:"content_text,omitempty"` + DiffType string `gorm:"type:varchar(16);comment:text_line/json_field" json:"diff_type"` + DiffPayload string `gorm:"type:longtext;comment:结构化diff(JSON)" json:"diff_payload,omitempty"` + Status string `gorm:"type:varchar(16);default:'pending';index;comment:状态" json:"status"` + HandledBy string `gorm:"type:varchar(128);comment:处理人" json:"handled_by,omitempty"` + DetectedAt time.Time `gorm:"not null;comment:发现时间" json:"detected_at"` + HandledAt *time.Time `json:"handled_at,omitempty"` +} + +// TableName 指定表名 // Logan.Li +func (SiteContentChange) TableName() string { return "monitor_site_content_changes" } + +// 内容变更状态常量 // Logan.Li +const ( + ContentChangePending = "pending" + ContentChangeAccepted = "accepted" + ContentChangeIgnored = "ignored" +) diff --git a/backend/pkg/database/database.go b/backend/pkg/database/database.go index 5981be3..b72d086 100644 --- a/backend/pkg/database/database.go +++ b/backend/pkg/database/database.go @@ -171,6 +171,8 @@ func BootstrapSchema(db *gorm.DB) error { &model.SiteTask{}, // 站点拨测任务 // Logan.Li &model.SiteResult{}, // 拨测结果 // Logan.Li &model.SiteAlert{}, // 拨测告警 // Logan.Li + &model.SiteBaseline{}, // 内容监控基线 // Logan.Li + &model.SiteContentChange{}, // 内容变更记录 // Logan.Li &model.MonitorAlertRule{}, // 中间件/站点拨测告警规则 // Logan.Li &model.PrometheusDataSource{}, // Prometheus 数据源配置 // Logan.Li &model.ZabbixDataSource{}, // Zabbix 数据源配置 // Logan.Li diff --git a/backend/pkg/siteprobe/content.go b/backend/pkg/siteprobe/content.go new file mode 100644 index 0000000..12c7dac --- /dev/null +++ b/backend/pkg/siteprobe/content.go @@ -0,0 +1,462 @@ +// 内容监控核心:规范化 → 哈希 → 差异计算。 +// +// 设计目标:判断"接口返回的数据是否发生变化",而不是"服务是否可用"。 +// 因此必须先区分两种情况: +// - 采集异常(状态码非 2xx、空响应、错误页)→ 不应报"内容变更" +// - 内容变更(拿到了有效数据但与基线不同)→ 才告警 +// +// 规范化是关键:时间戳、nonce、trace_id 这类每次必变的字段必须先剔除, +// 否则会产生大量误报。 // Logan.Li +package siteprobe + +import ( + "crypto/sha256" + "encoding/hex" + "encoding/json" + "fmt" + "regexp" + "sort" + "strconv" + "strings" +) + +// ContentCheckMode 内容检查模式。 // Logan.Li +const ( + ContentModeOff = "off" + ContentModeText = "text" + ContentModeJSON = "json" +) + +// PathValue JSONPath 提取出的单个值。 // Logan.Li +type PathValue struct { + Path string + Value any +} + +// ContentSnapshot 规范化后的内容快照。 // Logan.Li +type ContentSnapshot struct { + // Canonical 用于计算哈希的规范化文本 + Canonical string + // Hash 规范化文本的 sha256 + Hash string + // Mode 产生该快照的模式 + Mode string + // Invalid 非空表示采集异常(此时不应参与变更比对) + Invalid string +} + +// BuildSnapshot 按任务配置把原始响应体规范化为可哈希的快照。 +// +// 返回 Invalid 非空表示"采集异常",调用方应将其视为拨测失败而不是内容变更。 // Logan.Li +func BuildSnapshot(mode, body string, paths, ignoreRules []string, minLength int, sortLines bool) ContentSnapshot { + snap := ContentSnapshot{Mode: mode} + + if mode == ContentModeOff { + snap.Invalid = "内容检查未启用" + return snap + } + trimmed := strings.TrimSpace(body) + if trimmed == "" { + snap.Invalid = "响应体为空" + return snap + } + // 最小长度用于挡住 CDN 拦截页、网关错误页这类"200 但内容不是数据"的情况。 // Logan.Li + if minLength > 0 && len(trimmed) < minLength { + snap.Invalid = fmt.Sprintf("响应体长度 %d 小于最小有效长度 %d", len(trimmed), minLength) + return snap + } + + var canonical string + var err error + switch mode { + case ContentModeJSON: + canonical, err = normalizeJSON(trimmed, paths, ignoreRules) + default: + canonical = normalizeText(trimmed, ignoreRules, sortLines) + } + if err != nil { + snap.Invalid = "内容规范化失败: " + err.Error() + return snap + } + if strings.TrimSpace(canonical) == "" { + snap.Invalid = "规范化后内容为空" + return snap + } + + snap.Canonical = canonical + sum := sha256.Sum256([]byte(canonical)) + snap.Hash = hex.EncodeToString(sum[:]) + return snap +} + +// normalizeText 文本模式规范化: +// 1. 统一换行、逐行去首尾空白、丢弃空行 +// 2. 按忽略规则做正则替换(时间戳/nonce 等) +// 3. 可选排序(顺序无关的清单类接口,如 IP 段列表) // Logan.Li +func normalizeText(body string, ignoreRules []string, sortLines bool) string { + body = strings.ReplaceAll(body, "\r\n", "\n") + body = strings.ReplaceAll(body, "\r", "\n") + + for _, rule := range ignoreRules { + rule = strings.TrimSpace(rule) + if rule == "" { + continue + } + if re, err := regexp.Compile(rule); err == nil { + body = re.ReplaceAllString(body, "") + } + } + + lines := make([]string, 0, 64) + for _, line := range strings.Split(body, "\n") { + line = strings.TrimSpace(line) + if line == "" { + continue + } + lines = append(lines, line) + } + if sortLines { + sort.Strings(lines) + } + return strings.Join(lines, "\n") +} + +// normalizeJSON JSON 模式规范化:只保留 JSONPath 指定的字段,其余一律不参与比对。 +// +// 这比"忽略规则"更可靠——没被点名的字段根本进不了哈希。 // Logan.Li +func normalizeJSON(body string, paths, ignoreRules []string) (string, error) { + var root any + if err := json.Unmarshal([]byte(body), &root); err != nil { + return "", fmt.Errorf("非法 JSON: %w", err) + } + if len(paths) == 0 { + return "", fmt.Errorf("JSON 模式必须至少配置一个 JSONPath") + } + + flat := map[string]any{} + for _, p := range paths { + p = strings.TrimSpace(p) + if p == "" { + continue + } + values, err := EvalPath(root, p) + if err != nil { + // 字段不存在不算致命错误,但要体现在快照里, + // 这样"字段从有到无"也能被识别为变更。 // Logan.Li + flat[p] = "" + continue + } + if len(values) == 1 { + flat[p] = scrubValue(values[0], ignoreRules) + } else { + list := make([]any, 0, len(values)) + for _, v := range values { + list = append(list, scrubValue(v, ignoreRules)) + } + flat[p] = list + } + } + + // json.Marshal 对 map 的 key 自动排序,天然得到稳定输出。 // Logan.Li + out, err := json.Marshal(flat) + if err != nil { + return "", err + } + return string(out), nil +} + +// scrubValue 对提取出的字段值应用忽略规则(字符串值按正则替换)。 // Logan.Li +func scrubValue(v any, ignoreRules []string) any { + s, ok := v.(string) + if !ok || len(ignoreRules) == 0 { + return v + } + for _, rule := range ignoreRules { + rule = strings.TrimSpace(rule) + if rule == "" { + continue + } + if re, err := regexp.Compile(rule); err == nil { + s = re.ReplaceAllString(s, "") + } + } + return s +} + +// ─────────────────────── JSONPath(轻量实现)─────────────────────── +// +// 支持语法(覆盖绝大多数接口监控场景): +// $.data.status 取字段 +// $.data.ips[0] 取数组下标 +// $.data.ips[*].ip 通配遍历数组 +// $[0] 根为数组时取下标 +// +// 不支持过滤表达式 [?()]、递归下降 ..、切片 [1:3]。 +// 刻意不引第三方库:实现只有几十行,且避免为一个小功能增加依赖风险。 // Logan.Li + +// EvalPath 计算 JSONPath,返回匹配到的所有值。 // Logan.Li +func EvalPath(root any, path string) ([]any, error) { + segs, err := parsePath(path) + if err != nil { + return nil, err + } + current := []any{root} + for _, seg := range segs { + next := make([]any, 0, len(current)) + for _, node := range current { + switch seg.kind { + case segField: + if m, ok := node.(map[string]any); ok { + if v, exists := m[seg.name]; exists { + next = append(next, v) + } + } + case segIndex: + if arr, ok := node.([]any); ok && seg.index >= 0 && seg.index < len(arr) { + next = append(next, arr[seg.index]) + } + case segWildcard: + if arr, ok := node.([]any); ok { + next = append(next, arr...) + } else if m, ok := node.(map[string]any); ok { + keys := make([]string, 0, len(m)) + for k := range m { + keys = append(keys, k) + } + sort.Strings(keys) + for _, k := range keys { + next = append(next, m[k]) + } + } + } + } + if len(next) == 0 { + return nil, fmt.Errorf("路径未命中: %s", path) + } + current = next + } + return current, nil +} + +const ( + segField = iota + segIndex + segWildcard +) + +type pathSegment struct { + kind int + name string + index int +} + +// parsePath 把 "$.a.b[0].c" 解析成段序列。 // Logan.Li +func parsePath(path string) ([]pathSegment, error) { + p := strings.TrimSpace(path) + if p == "" { + return nil, fmt.Errorf("JSONPath 为空") + } + if strings.HasPrefix(p, "$") { + p = p[1:] + } + segs := []pathSegment{} + i := 0 + for i < len(p) { + switch p[i] { + case '.': + i++ + start := i + for i < len(p) && p[i] != '.' && p[i] != '[' { + i++ + } + if i > start { + segs = append(segs, pathSegment{kind: segField, name: p[start:i]}) + } + case '[': + end := strings.IndexByte(p[i:], ']') + if end < 0 { + return nil, fmt.Errorf("JSONPath 方括号未闭合: %s", path) + } + inner := strings.TrimSpace(p[i+1 : i+end]) + if inner == "*" { + segs = append(segs, pathSegment{kind: segWildcard}) + } else if n, err := strconv.Atoi(inner); err == nil { + segs = append(segs, pathSegment{kind: segIndex, index: n}) + } else { + // ["key"] / ['key'] 写法 + inner = strings.Trim(inner, `"'`) + segs = append(segs, pathSegment{kind: segField, name: inner}) + } + i += end + 1 + default: + // 省略前导点的写法(如 "data.status") + start := i + for i < len(p) && p[i] != '.' && p[i] != '[' { + i++ + } + segs = append(segs, pathSegment{kind: segField, name: p[start:i]}) + } + } + if len(segs) == 0 { + return nil, fmt.Errorf("无效 JSONPath: %s", path) + } + return segs, nil +} + +// ─────────────────────── 差异计算 ─────────────────────── + +// TextDiffLine 文本行级差异(供并排视图渲染)。 // Logan.Li +type TextDiffLine struct { + Type string `json:"type"` // same / add / del + Old string `json:"old,omitempty"` + New string `json:"new,omitempty"` +} + +// FieldDiff 字段级差异(JSON 模式)。 // Logan.Li +type FieldDiff struct { + Path string `json:"path"` + Old string `json:"old"` + New string `json:"new"` + Kind string `json:"kind"` // changed / added / removed +} + +// DiffText 基于 LCS 的行级差异,规模受行数平方限制, +// 对接口响应这种量级(通常几十到几千行)足够。 // Logan.Li +func DiffText(oldText, newText string) []TextDiffLine { + oldLines := splitLines(oldText) + newLines := splitLines(newText) + + n, m := len(oldLines), len(newLines) + // 行数过大时退化为整体替换,避免 O(n*m) 内存爆掉。 // Logan.Li + if n*m > 4_000_000 { + out := make([]TextDiffLine, 0, n+m) + for _, l := range oldLines { + out = append(out, TextDiffLine{Type: "del", Old: l}) + } + for _, l := range newLines { + out = append(out, TextDiffLine{Type: "add", New: l}) + } + return out + } + + lcs := make([][]int, n+1) + for i := range lcs { + lcs[i] = make([]int, m+1) + } + for i := n - 1; i >= 0; i-- { + for j := m - 1; j >= 0; j-- { + if oldLines[i] == newLines[j] { + lcs[i][j] = lcs[i+1][j+1] + 1 + } else if lcs[i+1][j] >= lcs[i][j+1] { + lcs[i][j] = lcs[i+1][j] + } else { + lcs[i][j] = lcs[i][j+1] + } + } + } + + out := []TextDiffLine{} + i, j := 0, 0 + for i < n && j < m { + switch { + case oldLines[i] == newLines[j]: + out = append(out, TextDiffLine{Type: "same", Old: oldLines[i], New: newLines[j]}) + i++ + j++ + case lcs[i+1][j] >= lcs[i][j+1]: + out = append(out, TextDiffLine{Type: "del", Old: oldLines[i]}) + i++ + default: + out = append(out, TextDiffLine{Type: "add", New: newLines[j]}) + j++ + } + } + for ; i < n; i++ { + out = append(out, TextDiffLine{Type: "del", Old: oldLines[i]}) + } + for ; j < m; j++ { + out = append(out, TextDiffLine{Type: "add", New: newLines[j]}) + } + return out +} + +// DiffJSONFields 字段级差异:两份规范化 JSON 逐 key 比对。 // Logan.Li +func DiffJSONFields(oldText, newText string) []FieldDiff { + oldMap := map[string]any{} + newMap := map[string]any{} + _ = json.Unmarshal([]byte(oldText), &oldMap) + _ = json.Unmarshal([]byte(newText), &newMap) + + keys := map[string]struct{}{} + for k := range oldMap { + keys[k] = struct{}{} + } + for k := range newMap { + keys[k] = struct{}{} + } + sorted := make([]string, 0, len(keys)) + for k := range keys { + sorted = append(sorted, k) + } + sort.Strings(sorted) + + out := []FieldDiff{} + for _, k := range sorted { + ov, oOK := oldMap[k] + nv, nOK := newMap[k] + os := stringifyValue(ov) + ns := stringifyValue(nv) + switch { + case oOK && !nOK: + out = append(out, FieldDiff{Path: k, Old: os, New: "", Kind: "removed"}) + case !oOK && nOK: + out = append(out, FieldDiff{Path: k, Old: "", New: ns, Kind: "added"}) + case os != ns: + out = append(out, FieldDiff{Path: k, Old: os, New: ns, Kind: "changed"}) + } + } + return out +} + +func splitLines(s string) []string { + s = strings.ReplaceAll(s, "\r\n", "\n") + if s == "" { + return nil + } + return strings.Split(s, "\n") +} + +// stringifyValue 把任意值转成便于展示的字符串。 // Logan.Li +func stringifyValue(v any) string { + if v == nil { + return "" + } + if s, ok := v.(string); ok { + return s + } + b, err := json.Marshal(v) + if err != nil { + return fmt.Sprintf("%v", v) + } + return string(b) +} + +// ParseRules 把前端传来的 JSON 数组或换行分隔文本解析成规则列表, +// 兼容用户直接粘贴多行正则的写法。 // Logan.Li +func ParseRules(raw string) []string { + raw = strings.TrimSpace(raw) + if raw == "" { + return nil + } + var arr []string + if strings.HasPrefix(raw, "[") && json.Unmarshal([]byte(raw), &arr) == nil { + return arr + } + out := []string{} + for _, line := range strings.Split(raw, "\n") { + if line = strings.TrimSpace(line); line != "" { + out = append(out, line) + } + } + return out +} diff --git a/backend/pkg/siteprobe/content_store.go b/backend/pkg/siteprobe/content_store.go new file mode 100644 index 0000000..8d58251 --- /dev/null +++ b/backend/pkg/siteprobe/content_store.go @@ -0,0 +1,246 @@ +// 内容监控的落库逻辑:基线建立、变更判定、防抖、人工处理。 +// +// 判定原则(避免误报的核心): +// 1. 拨测本身失败(连不上/超时/非预期状态码)→ 完全不参与内容比对 +// 2. 拿到响应但规范化判定为"采集异常"(空/过短/非 JSON)→ 也不参与比对 +// 3. 只有"拿到了有效数据且与基线不同"才算内容变更 +package siteprobe + +import ( + "encoding/json" + "fmt" + "time" + + "ezops/model" + + "gorm.io/gorm" + "gorm.io/gorm/clause" +) + +// ApplyContentCheck 对一次成功拨测的响应体执行内容比对。 +// +// 返回 changed=true 表示本次达到确认阈值、已写入变更记录(应触发告警)。 // Logan.Li +func ApplyContentCheck(db *gorm.DB, task *model.SiteTask, body string, probeOK bool) (bool, error) { + if task == nil || !ContentCheckEnabled(task.ContentCheckMode) { + return false, nil + } + // 拨测本身失败时不做内容判定:否则网络抖动会被误报成"内容变更"。 // Logan.Li + if !probeOK { + return false, nil + } + + snap := BuildSnapshot( + task.ContentCheckMode, + body, + ParseRules(task.ContentPaths), + ParseRules(task.ContentIgnoreRules), + task.ContentMinLength, + task.ContentSortLines, + ) + if snap.Invalid != "" { + // 采集异常:既不算变更,也不覆盖基线,等下次恢复。 + return false, fmt.Errorf("内容采集异常: %s", snap.Invalid) + } + + // 无基线 → 自动建立 v1(按用户选择采用"自动接受",否则新任务永远跑不动)。 + // 必须用行锁串行化:RunTaskNow 与后台 worker 可能同时拨测, + // 不加锁会各自看到 current_baseline_id=0 并各建一条基线(实测出现 v1/v2 重复)。 // Logan.Li + if task.CurrentBaselineID == 0 { + return false, db.Transaction(func(tx *gorm.DB) error { + var locked model.SiteTask + if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).First(&locked, task.ID).Error; err != nil { + return err + } + if locked.CurrentBaselineID != 0 { + // 已被其它协程建立,本次直接返回。 // Logan.Li + task.CurrentBaselineID = locked.CurrentBaselineID + return nil + } + baseline, err := CreateBaseline(tx, task.ID, snap, "auto", "", "首次采集自动建立基线") + if err != nil { + return err + } + return tx.Model(&model.SiteTask{}).Where("id = ?", task.ID). + Updates(map[string]interface{}{ + "current_baseline_id": baseline.ID, + "pending_change_count": 0, + }).Error + }) + } + + var baseline model.SiteBaseline + if err := db.First(&baseline, task.CurrentBaselineID).Error; err != nil { + // 基线丢了(被清理/迁移),重新建立而不是刷变更。 // Logan.Li + nb, cerr := CreateBaseline(db, task.ID, snap, "auto", "", "基线缺失,自动重建") + if cerr != nil { + return false, cerr + } + db.Model(&model.SiteTask{}).Where("id = ?", task.ID). + Update("current_baseline_id", nb.ID) + return false, nil + } + + // 内容与基线一致 → 重置连续计数,说明之前的"变更"是抖动。 + if snap.Hash == baseline.ContentHash { + if task.PendingChangeCount > 0 { + db.Model(&model.SiteTask{}).Where("id = ?", task.ID). + Update("pending_change_count", 0) + } + return false, nil + } + + // 内容不同 → 累计连续次数,达到阈值才落变更记录并告警。 // Logan.Li + confirmTimes := task.ContentConfirmTimes + if confirmTimes <= 0 { + confirmTimes = 2 + } + pending := task.PendingChangeCount + 1 + if pending < confirmTimes { + db.Model(&model.SiteTask{}).Where("id = ?", task.ID). + Update("pending_change_count", pending) + return false, nil + } + + // 同一 hash 的 pending 变更已存在时不重复创建,避免连续两轮各写一条。 // Logan.Li + var existing model.SiteContentChange + if err := db.Where("task_id = ? AND status = ? AND content_hash = ?", + task.ID, model.ContentChangePending, snap.Hash). + Order("id DESC").Take(&existing).Error; err == nil { + db.Model(&model.SiteTask{}).Where("id = ?", task.ID). + Update("pending_change_count", 0) + return false, nil + } + + diffType := "text_line" + var payload any + if task.ContentCheckMode == ContentModeJSON { + diffType = "json_field" + payload = DiffJSONFields(baseline.ContentText, snap.Canonical) + } else { + payload = DiffText(baseline.ContentText, snap.Canonical) + } + diffJSON, _ := json.Marshal(payload) + + change := model.SiteContentChange{ + TaskID: task.ID, + FromBaselineID: baseline.ID, + FromVersion: baseline.Version, + ContentHash: snap.Hash, + ContentText: snap.Canonical, + DiffType: diffType, + DiffPayload: string(diffJSON), + Status: model.ContentChangePending, + DetectedAt: time.Now(), + } + if err := db.Create(&change).Error; err != nil { + return false, err + } + db.Model(&model.SiteTask{}).Where("id = ?", task.ID). + Update("pending_change_count", 0) + return true, nil +} + +// CreateBaseline 追加一个新版本基线。 // Logan.Li +func CreateBaseline(db *gorm.DB, taskID uint, snap ContentSnapshot, source, acceptedBy, note string) (*model.SiteBaseline, error) { + var maxVersion int + db.Model(&model.SiteBaseline{}).Where("task_id = ?", taskID). + Select("COALESCE(MAX(version), 0)").Scan(&maxVersion) + + baseline := model.SiteBaseline{ + TaskID: taskID, + Version: maxVersion + 1, + ContentHash: snap.Hash, + ContentText: snap.Canonical, + Source: source, + AcceptedBy: acceptedBy, + Note: note, + CreatedAt: time.Now(), + } + if err := db.Create(&baseline).Error; err != nil { + return nil, err + } + return &baseline, nil +} + +// AcceptChange 把某次变更接受为新基线。 +// +// 只有人工确认才走这里:真实变更(上游确实改了)与误报(我方配置问题)需人工判断, +// 自动接受会让告警失去意义。 // Logan.Li +func AcceptChange(db *gorm.DB, changeID uint, acceptedBy, note string) error { + var change model.SiteContentChange + if err := db.First(&change, changeID).Error; err != nil { + return err + } + if change.Status != model.ContentChangePending { + return fmt.Errorf("该变更已处理(当前状态 %s)", change.Status) + } + + var task model.SiteTask + if err := db.First(&task, change.TaskID).Error; err != nil { + return err + } + + snap := ContentSnapshot{ + Mode: task.ContentCheckMode, + Canonical: change.ContentText, + Hash: change.ContentHash, + } + baseline, err := CreateBaseline(db, change.TaskID, snap, "manual", acceptedBy, note) + if err != nil { + return err + } + + now := time.Now() + return db.Transaction(func(tx *gorm.DB) error { + if err := tx.Model(&model.SiteContentChange{}).Where("id = ?", change.ID). + Updates(map[string]interface{}{ + "status": model.ContentChangeAccepted, + "handled_by": acceptedBy, + "handled_at": now, + }).Error; err != nil { + return err + } + // 同一基线的其它 pending 变更一并作废,避免重复要求确认。 + if err := tx.Model(&model.SiteContentChange{}). + Where("task_id = ? AND status = ? AND from_baseline_id = ?", + change.TaskID, model.ContentChangePending, change.FromBaselineID). + Updates(map[string]interface{}{ + "status": model.ContentChangeAccepted, + "handled_by": acceptedBy, + "handled_at": now, + }).Error; err != nil { + return err + } + return tx.Model(&model.SiteTask{}).Where("id = ?", change.TaskID). + Updates(map[string]interface{}{ + "current_baseline_id": baseline.ID, + "pending_change_count": 0, + }).Error + }) +} + +// IgnoreChange 把变更标记为误报忽略(不更新基线)。 +// +// 忽略后如果内容仍未恢复,下一轮会再次累积到阈值并重新产生变更记录, +// 这是期望行为——避免"忽略一次就永久静默"。 // Logan.Li +func IgnoreChange(db *gorm.DB, changeID uint, handledBy string) error { + var change model.SiteContentChange + if err := db.First(&change, changeID).Error; err != nil { + return err + } + if change.Status != model.ContentChangePending { + return fmt.Errorf("该变更已处理(当前状态 %s)", change.Status) + } + now := time.Now() + return db.Model(&model.SiteContentChange{}).Where("id = ?", change.ID). + Updates(map[string]interface{}{ + "status": model.ContentChangeIgnored, + "handled_by": handledBy, + "handled_at": now, + }).Error +} + +// ContentCheckEnabled 判断任务是否启用了内容监控。 // Logan.Li +func ContentCheckEnabled(mode string) bool { + return mode == ContentModeText || mode == ContentModeJSON +} diff --git a/backend/pkg/siteprobe/content_test.go b/backend/pkg/siteprobe/content_test.go new file mode 100644 index 0000000..e0d93a9 --- /dev/null +++ b/backend/pkg/siteprobe/content_test.go @@ -0,0 +1,191 @@ +package siteprobe + +import ( + "strings" + "testing" +) + +// TestEvalPath 覆盖接口监控最常见的几种 JSONPath 写法。 // Logan.Li +func TestEvalPath(t *testing.T) { + doc := map[string]any{ + "data": map[string]any{ + "status": "ok", + "ips": []any{ + map[string]any{"ip": "1.1.1.1"}, + map[string]any{"ip": "2.2.2.2"}, + }, + }, + } + + cases := []struct { + path string + want string + }{ + {"$.data.status", "ok"}, + {"data.status", "ok"}, + {"$.data.ips[0].ip", "1.1.1.1"}, + {"$['data']['status']", "ok"}, + } + for _, c := range cases { + got, err := EvalPath(doc, c.path) + if err != nil { + t.Fatalf("%s 解析失败: %v", c.path, err) + } + if len(got) != 1 || got[0] != c.want { + t.Fatalf("%s 期望 %q,实际 %v", c.path, c.want, got) + } + } + + // 通配 + got, err := EvalPath(doc, "$.data.ips[*].ip") + if err != nil || len(got) != 2 { + t.Fatalf("通配解析失败: %v %v", got, err) + } + + // 未命中应返回错误 + if _, err := EvalPath(doc, "$.data.nope"); err == nil { + t.Fatal("未命中路径应返回错误") + } +} + +// TestNormalizeTextIgnoresVolatileFields 时间戳变化不应触发内容变更。 // Logan.Li +func TestNormalizeTextIgnoresVolatileFields(t *testing.T) { + a := "173.245.48.0/20\nupdated_at: 2026-09-14T10:00:00Z" + b := "173.245.48.0/20\nupdated_at: 2026-09-15T22:31:09Z" + rules := []string{`updated_at:.*`} + + sa := BuildSnapshot(ContentModeText, a, nil, rules, 0, false) + sb := BuildSnapshot(ContentModeText, b, nil, rules, 0, false) + if sa.Invalid != "" || sb.Invalid != "" { + t.Fatalf("快照构建失败: %s / %s", sa.Invalid, sb.Invalid) + } + if sa.Hash != sb.Hash { + t.Fatalf("仅时间戳不同,哈希应一致: %s != %s", sa.Canonical, sb.Canonical) + } +} + +// TestNormalizeTextSortLines IP 段顺序变化不应视为变更。 // Logan.Li +func TestNormalizeTextSortLines(t *testing.T) { + a := "103.21.244.0/22\n173.245.48.0/20" + b := "173.245.48.0/20\n103.21.244.0/22" + + unsortedA := BuildSnapshot(ContentModeText, a, nil, nil, 0, false) + unsortedB := BuildSnapshot(ContentModeText, b, nil, nil, 0, false) + if unsortedA.Hash == unsortedB.Hash { + t.Fatal("未排序时顺序不同应视为不同内容") + } + + sortedA := BuildSnapshot(ContentModeText, a, nil, nil, 0, true) + sortedB := BuildSnapshot(ContentModeText, b, nil, nil, 0, true) + if sortedA.Hash != sortedB.Hash { + t.Fatalf("排序后应一致: %s / %s", sortedA.Canonical, sortedB.Canonical) + } +} + +// TestBuildSnapshotInvalid 采集异常不应被当成内容变更。 // Logan.Li +func TestBuildSnapshotInvalid(t *testing.T) { + // 空响应 + if s := BuildSnapshot(ContentModeText, " ", nil, nil, 0, false); s.Invalid == "" { + t.Fatal("空响应应判为采集异常") + } + // 过短(模拟 CDN 拦截页) + if s := BuildSnapshot(ContentModeText, "Access denied", nil, nil, 100, false); s.Invalid == "" { + t.Fatal("短于最小长度应判为采集异常") + } + // JSON 模式拿到 HTML + if s := BuildSnapshot(ContentModeJSON, "error", []string{"$.a"}, nil, 0, false); s.Invalid == "" { + t.Fatal("非 JSON 应判为采集异常") + } + // 正常 + if s := BuildSnapshot(ContentModeText, "1.1.1.1\n2.2.2.2", nil, nil, 5, false); s.Invalid != "" { + t.Fatalf("正常内容不应报错: %s", s.Invalid) + } +} + +// TestJSONModeOnlyWatchesSelectedFields 未被点名的字段变化不应影响哈希。 // Logan.Li +func TestJSONModeOnlyWatchesSelectedFields(t *testing.T) { + a := `{"data":{"status":"ok","trace_id":"aaa"},"ts":1}` + b := `{"data":{"status":"ok","trace_id":"bbb"},"ts":2}` + + sa := BuildSnapshot(ContentModeJSON, a, []string{"$.data.status"}, nil, 0, false) + sb := BuildSnapshot(ContentModeJSON, b, []string{"$.data.status"}, nil, 0, false) + if sa.Hash != sb.Hash { + t.Fatalf("只监控 $.data.status 时 trace_id/ts 变化不应影响: %s / %s", sa.Canonical, sb.Canonical) + } + + // status 变化必须被识别 + c := `{"data":{"status":"degraded","trace_id":"aaa"},"ts":1}` + sc := BuildSnapshot(ContentModeJSON, c, []string{"$.data.status"}, nil, 0, false) + if sc.Hash == sa.Hash { + t.Fatal("status 变化应改变哈希") + } +} + +// TestDiffText 行级差异。 // Logan.Li +func TestDiffText(t *testing.T) { + old := "a\nb\nc" + new := "a\nc\nd" + diff := DiffText(old, new) + var adds, dels int + for _, d := range diff { + switch d.Type { + case "add": + adds++ + case "del": + dels++ + } + } + if adds != 1 || dels != 1 { + t.Fatalf("期望 1 增 1 删,实际 add=%d del=%d: %+v", adds, dels, diff) + } +} + +// TestDiffJSONFields 字段级差异。 // Logan.Li +func TestDiffJSONFields(t *testing.T) { + old := `{"$.data.status":"ok","$.data.level":1}` + new := `{"$.data.status":"degraded","$.data.level":1,"$.data.extra":"x"}` + diff := DiffJSONFields(old, new) + + var changed, added int + for _, d := range diff { + if d.Kind == "changed" && d.Path == "$.data.status" { + changed++ + if d.Old != "ok" || d.New != "degraded" { + t.Fatalf("字段值错误: %+v", d) + } + } + if d.Kind == "added" && d.Path == "$.data.extra" { + added++ + } + } + if changed != 1 || added != 1 { + t.Fatalf("期望 1 改 1 增,实际: %+v", diff) + } +} + +// TestParseRules 兼容 JSON 数组与多行文本两种输入。 // Logan.Li +func TestParseRules(t *testing.T) { + if r := ParseRules(`["a","b"]`); len(r) != 2 { + t.Fatalf("JSON 数组解析失败: %v", r) + } + if r := ParseRules("a\nb\n"); len(r) != 2 { + t.Fatalf("多行解析失败: %v", r) + } + if r := ParseRules(" "); r != nil { + t.Fatalf("空输入应返回 nil: %v", r) + } +} + +// TestNormalizeTextDropsBlankLines 空行与首尾空白不应影响比对。 // Logan.Li +func TestNormalizeTextDropsBlankLines(t *testing.T) { + a := "1.1.1.1\n\n2.2.2.2\n" + b := " 1.1.1.1 \n2.2.2.2" + sa := BuildSnapshot(ContentModeText, a, nil, nil, 0, false) + sb := BuildSnapshot(ContentModeText, b, nil, nil, 0, false) + if sa.Hash != sb.Hash { + t.Fatalf("空行/空白不应影响: %q vs %q", sa.Canonical, sb.Canonical) + } + if strings.Contains(sa.Canonical, "\n\n") { + t.Fatal("规范化后不应保留空行") + } +} diff --git a/backend/pkg/siteprobe/executor.go b/backend/pkg/siteprobe/executor.go index f1c8fec..79b4f78 100644 --- a/backend/pkg/siteprobe/executor.go +++ b/backend/pkg/siteprobe/executor.go @@ -4,6 +4,7 @@ import ( "context" "crypto/tls" "fmt" + "io" "net" "net/http" "os/exec" @@ -28,6 +29,8 @@ type ProbeOutcome struct { SSLCertIssuer string SSLCertSubject string SSLDaysRemaining int + // Body 响应体(内容监控用,按上限截断;非 HTTP 拨测为空) // Logan.Li + Body string } // Execute 按任务类型执行拨测 // Logan.Li @@ -133,11 +136,64 @@ func probeHTTP(ctx context.Context, task model.SiteTask) ProbeOutcome { ErrorMessage: fmt.Sprintf("状态码 %d 不符合期望 %d", resp.StatusCode, task.ExpectedStatus), } } + + // 读取响应体:内容监控与 expected_content/expected_length 都依赖它。 + // 上限 4MB,避免误配大文件接口把内存打满。 // Logan.Li + const maxBodyBytes = 4 << 20 + raw, readErr := io.ReadAll(io.LimitReader(resp.Body, maxBodyBytes)) + if readErr != nil { + return ProbeOutcome{ + Status: model.SiteResultFail, + HTTPStatus: resp.StatusCode, + ResponseTime: elapsed, + TotalTime: elapsed, + ErrorMessage: "读取响应体失败: " + readErr.Error(), + } + } + body := string(raw) + + // 此前这两个字段只有表结构、执行器从未校验,属于"定义了但没实现"。 + // 内容监控依赖它们做正向断言与错误页拦截,此处一并补齐。 // Logan.Li + if task.ExpectedLength > 0 && len(raw) != task.ExpectedLength { + return ProbeOutcome{ + Status: model.SiteResultFail, + HTTPStatus: resp.StatusCode, + ResponseTime: elapsed, + TotalTime: elapsed, + Body: body, + ErrorMessage: fmt.Sprintf("响应长度 %d 不符合期望 %d", len(raw), task.ExpectedLength), + } + } + if exp := strings.TrimSpace(task.ExpectedContent); exp != "" { + re, reErr := regexp.Compile(exp) + if reErr != nil { + return ProbeOutcome{ + Status: model.SiteResultFail, + HTTPStatus: resp.StatusCode, + ResponseTime: elapsed, + TotalTime: elapsed, + Body: body, + ErrorMessage: "expected_content 正则无效: " + reErr.Error(), + } + } + if !re.MatchString(body) { + return ProbeOutcome{ + Status: model.SiteResultFail, + HTTPStatus: resp.StatusCode, + ResponseTime: elapsed, + TotalTime: elapsed, + Body: body, + ErrorMessage: "响应内容未匹配 expected_content", + } + } + } + return ProbeOutcome{ Status: model.SiteResultSuccess, HTTPStatus: resp.StatusCode, ResponseTime: elapsed, TotalTime: elapsed, + Body: body, } } diff --git a/backend/pkg/siteprobe/worker.go b/backend/pkg/siteprobe/worker.go index 57952d2..0dbbd87 100644 --- a/backend/pkg/siteprobe/worker.go +++ b/backend/pkg/siteprobe/worker.go @@ -124,4 +124,17 @@ func (w *Worker) probeTask(task model.SiteTask) { }) // 拨测恢复后即时关闭探测类告警,与统计栏 down_count 同步 // Logan.Li ResolveRecoveredProbeAlerts(w.db, task.ID, lastResult) + + // 内容监控:只有在拨测成功时才参与内容比对, + // 避免网络抖动被误报成"接口返回内容变了"。 // Logan.Li + if ContentCheckEnabled(task.ContentCheckMode) { + changed, err := ApplyContentCheck(w.db, &task, outcome.Body, outcome.Status == model.SiteResultSuccess) + switch { + case err != nil: + // 采集异常(空响应/过短/非JSON)走这里,不算变更。 + log.Printf("[siteprobe] 任务 %d 内容检查跳过: %v", task.ID, err) + case changed: + log.Printf("[siteprobe] 任务 %d 检测到内容变更,已生成待确认记录", task.ID) + } + } } \ No newline at end of file diff --git a/backend/router/routes.go b/backend/router/routes.go index 5b681e8..84bcfa8 100644 --- a/backend/router/routes.go +++ b/backend/router/routes.go @@ -1104,6 +1104,13 @@ func Register(r *gin.Engine, db *gorm.DB, log *zap.Logger, cfg *config.Config, c siteGroup.POST("/tasks/:id/probe", siteHandler.ProbeNow) siteGroup.GET("/results", siteHandler.ListResults) siteGroup.GET("/stats", siteHandler.GetSiteStats) + + // 内容监控:基线版本 / 变更记录 / 人工确认 // Logan.Li + siteGroup.GET("/tasks/:id/content", siteHandler.GetTaskContent) + siteGroup.GET("/tasks/:id/baselines", siteHandler.ListBaselines) + siteGroup.GET("/tasks/:id/changes", siteHandler.ListContentChanges) + siteGroup.POST("/changes/:id/accept", siteHandler.AcceptContentChange) + siteGroup.POST("/changes/:id/ignore", siteHandler.IgnoreContentChange) } siteProbeWorker.Start(10) diff --git a/frontend/src/api/monitor/site.ts b/frontend/src/api/monitor/site.ts index d1796d2..c772816 100644 --- a/frontend/src/api/monitor/site.ts +++ b/frontend/src/api/monitor/site.ts @@ -2,6 +2,16 @@ import { http } from '@/utils/request'; +// 内容监控配置(随任务一起提交) // Logan.Li +export interface ContentCheckPayload { + content_check_mode?: 'off' | 'text' | 'json'; + content_paths?: string; + content_ignore_rules?: string; + content_min_length?: number; + content_confirm_times?: number; + content_sort_lines?: boolean; +} + export const siteApi = { getStats: async () => http.get('/site/stats'), getTasks: async () => http.get<{ list: unknown[]; total: number }>('/site/tasks'), @@ -9,12 +19,12 @@ export const siteApi = { name: string; url: string; type: string; frequency_sec?: number; timeout_sec?: number; expected_status_code?: number; enabled?: boolean; - }) => http.post('/site/tasks', data), + } & ContentCheckPayload) => http.post('/site/tasks', data), updateTask: async (id: number | string, data: { name?: string; url?: string; type?: string; frequency_sec?: number; timeout_sec?: number; expected_status_code?: number; enabled?: boolean; - }) => http.put(`/site/tasks/${id}`, data), + } & ContentCheckPayload) => http.put(`/site/tasks/${id}`, data), deleteTask: async (id: number | string) => http.delete(`/site/tasks/${id}`), toggleTask: async (id: number | string, enabled: boolean) => http.put(`/site/tasks/${id}`, { enabled }), @@ -27,4 +37,19 @@ export const siteApi = { limit: opts?.limit ?? 500, ...(opts?.hours ? { hours: opts.hours } : {}), }), -}; \ No newline at end of file + + // ── 内容监控 ────────────────────────────────────────────── + /** 当前基线 + 待确认变更 + 配置,一次拿全供详情页渲染 */ + getTaskContent: async (id: number | string) => http.get(`/site/tasks/${id}/content`), + /** 基线版本历史 */ + getBaselines: async (id: number | string) => http.get(`/site/tasks/${id}/baselines`), + /** 变更记录,可按 status 过滤 */ + getContentChanges: async (id: number | string, status?: string) => + http.get(`/site/tasks/${id}/changes`, status ? { status } : undefined), + /** 接受为新基线(人工确认) */ + acceptChange: async (changeId: number | string, note?: string) => + http.post(`/site/changes/${changeId}/accept`, { note: note ?? '' }), + /** 标记为误报忽略 */ + ignoreChange: async (changeId: number | string) => + http.post(`/site/changes/${changeId}/ignore`), +}; diff --git a/frontend/src/pages/monitor/SiteMonitor.tsx b/frontend/src/pages/monitor/SiteMonitor.tsx index a11a6bd..76ca6d6 100644 --- a/frontend/src/pages/monitor/SiteMonitor.tsx +++ b/frontend/src/pages/monitor/SiteMonitor.tsx @@ -33,11 +33,24 @@ import { interface CreateTaskForm { name: string; url: string; type: ProbeType; frequency_sec: number; timeout_sec: number; expected_status_code: number; + // 内容监控 // Logan.Li + content_check_mode: 'off' | 'text' | 'json'; + content_paths: string; + content_ignore_rules: string; + content_min_length: number; + content_confirm_times: number; + content_sort_lines: boolean; } const emptyForm: CreateTaskForm = { name: '', url: '', type: 'HTTP', frequency_sec: defaultProbeFrequency('HTTP'), timeout_sec: 10, expected_status_code: 200, + content_check_mode: 'off', + content_paths: '', + content_ignore_rules: '', + content_min_length: 0, + content_confirm_times: 2, + content_sort_lines: false, }; const FILTER_TYPES: Array = ['ALL', ...PROBE_TYPE_OPTIONS]; @@ -124,6 +137,13 @@ export function SiteMonitor() { frequency_sec: task.frequency_sec || 60, timeout_sec: task.timeout_sec || 10, expected_status_code: task.expected_status_code || 200, + // 回填内容监控配置,否则编辑保存会把已配好的规则清空 // Logan.Li + content_check_mode: (task.content_check_mode as CreateTaskForm['content_check_mode']) || 'off', + content_paths: task.content_paths || '', + content_ignore_rules: task.content_ignore_rules || '', + content_min_length: task.content_min_length || 0, + content_confirm_times: task.content_confirm_times || 2, + content_sort_lines: Boolean(task.content_sort_lines), }); setFormOpen(true); }; @@ -144,6 +164,13 @@ export function SiteMonitor() { frequency_sec: Number(form.frequency_sec) || 60, timeout_sec: Number(form.timeout_sec) || 10, expected_status_code: Number(form.expected_status_code) || 200, + // 内容监控:off 时后端会忽略其余字段并清空基线 // Logan.Li + content_check_mode: form.content_check_mode, + content_paths: form.content_paths, + content_ignore_rules: form.content_ignore_rules, + content_min_length: Number(form.content_min_length) || 0, + content_confirm_times: Number(form.content_confirm_times) || 2, + content_sort_lines: form.content_sort_lines, }; try { if (editingTaskId) { await siteApi.updateTask(editingTaskId, payload); toast.success(i18n.t('monitor.sitemonitortsx.se01498ee')); } @@ -388,6 +415,76 @@ export function SiteMonitor() { onChange={(e) => setForm({ ...form, expected_status_code: Number(e.target.value) || 200 })} /> )} + {form.type === 'HTTP' && ( + <> +
+ + +

+ 开启后,返回内容与基线不同时告警。只有拨测成功才参与比对,避免错误页被误报成"内容变更"。 +

+
+ + {form.content_check_mode === 'json' && ( +
+ +