Files
Logan.Li 539db36f2a
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
feat(site): 站点拨测支持接口返回内容监控(文本 + JSON 字段)
需求:监控接口返回的数据本身是否变化(如 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
2026-10-18 21:41:00 +00:00

463 lines
12 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 内容监控核心:规范化 → 哈希 → 差异计算。
//
// 设计目标:判断"接口返回的数据是否发生变化",而不是"服务是否可用"。
// 因此必须先区分两种情况:
// - 采集异常(状态码非 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
}