feat(site): 站点拨测支持接口返回内容监控(文本 + JSON 字段)
AIOps CI / Backend (test, vet, build) (push) Canceled after 0s
AIOps CI / Agent (test, vet, build) (push) Canceled after 0s
AIOps CI / Frontend (typecheck, lint, build) (push) Canceled after 0s
AIOps CI / API integration (SQLite + redka) (push) Canceled after 0s

需求:监控接口返回的数据本身是否变化(如 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
This commit is contained in:
Logan.Li committed 2026-10-18 21:41:00 +00:00
1 parent 286d7cd906
commit 539db36f2a
18 files changed
+1704 -3

No files matched your search

+462
View File
@@ -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] = "<missing>"
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
}
+246
View File
@@ -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
}
+191
View File
@@ -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, "<html>error</html>", []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("规范化后不应保留空行")
}
}
+56
View File
@@ -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,
}
}
+13
View File
@@ -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)
}
}
}