需求:监控接口返回的数据本身是否变化(如 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
192 lines
5.8 KiB
Go
192 lines
5.8 KiB
Go
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("规范化后不应保留空行")
|
|
}
|
|
}
|