需求:监控接口返回的数据本身是否变化(如 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
463 lines
12 KiB
Go
463 lines
12 KiB
Go
// 内容监控核心:规范化 → 哈希 → 差异计算。
|
||
//
|
||
// 设计目标:判断"接口返回的数据是否发生变化",而不是"服务是否可用"。
|
||
// 因此必须先区分两种情况:
|
||
// - 采集异常(状态码非 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
|
||
}
|