需求:监控接口返回的数据本身是否变化(如 Cloudflare ips-v4 的 IP 段清单),
而不是只看服务是否可用。两者是不同的问题:服务 200 OK 但内容变了,拨测发现不了。
后端
- SiteTask 新增 content_check_mode(off/text/json)、content_paths(JSONPath)、
content_ignore_rules、content_min_length、content_confirm_times、content_sort_lines
- 新增 monitor_site_baselines(基线多版本)与 monitor_site_content_changes(变更记录)
- pkg/siteprobe/content.go:零依赖 JSONPath($.a.b[0]、$.a[*].b)、文本/JSON 规范化、
sha256 哈希、LCS 行级 diff、字段级 diff
- 核心判定区分「采集异常」与「内容变更」:状态码非 2xx、空响应、短于最小长度、
非合法 JSON 均不算变更,避免 CDN 拦截页造成误报
- 连续 N 次(默认 2)不一致才落变更记录,抑制抖动
- 基线首次自动建立(用户选择自动接受),之后变更需人工「接受为新基线」并留审计
- 补齐历史遗留死字段:expected_content(正则断言)与 expected_length 此前
只有表结构、执行器从未校验,现已实现
- 告警引擎新增 content_changed 指标,人工处理后自动恢复
- 迁移 0006 用 AutoMigrate 实现幂等(空库 0000 已建表,静态 ALTER 会重复列)
前端
- 任务表单新增内容监控配置区(模式/JSONPath/忽略规则/最小长度/确认次数/排序)
- 详情页新增内容监控面板:当前基线、待确认变更、并排 diff、版本历史、
接受/忽略操作
验证
- 9 项单元测试(JSONPath、时间戳忽略、顺序无关、采集异常、字段级监控、diff)
- 端到端:Cloudflare ips-v4 建基线 v1 → 模拟变更 → 第 2 轮告警 → 接受为 v2
- JSON 模式实测快照仅含指定字段:{"$.db_driver":"mysql","$.version":"v1.0.2"}
// Logan.Li
247 lines
8.2 KiB
Go
247 lines
8.2 KiB
Go
// 内容监控的落库逻辑:基线建立、变更判定、防抖、人工处理。
|
|
//
|
|
// 判定原则(避免误报的核心):
|
|
// 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
|
|
}
|