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

+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,
}
}