需求:监控接口返回的数据本身是否变化(如 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
330 lines
10 KiB
Go
330 lines
10 KiB
Go
package controller
|
|
|
|
import (
|
|
"net/http"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
"ezops/model"
|
|
"ezops/pkg/siteprobe"
|
|
|
|
"github.com/gin-gonic/gin"
|
|
"gorm.io/gorm"
|
|
)
|
|
|
|
// SiteHandler 站点拨测管理 handler // Logan.Li
|
|
type SiteHandler struct {
|
|
db *gorm.DB
|
|
worker *siteprobe.Worker
|
|
}
|
|
|
|
// NewSiteHandler 创建站点拨测 handler
|
|
func NewSiteHandler(db *gorm.DB, worker *siteprobe.Worker) *SiteHandler {
|
|
return &SiteHandler{db: db, worker: worker}
|
|
}
|
|
|
|
// ListTasks 列出拨测任务
|
|
// GET /api/v1/site/tasks
|
|
func (h *SiteHandler) ListTasks(c *gin.Context) {
|
|
var tasks []model.SiteTask
|
|
q := h.db.Model(&model.SiteTask{})
|
|
|
|
if t := c.Query("type"); t != "" {
|
|
q = q.Where("task_type = ?", strings.ToLower(t))
|
|
}
|
|
if kw := c.Query("keyword"); kw != "" {
|
|
q = q.Where("name LIKE ? OR url LIKE ?", "%"+kw+"%", "%"+kw+"%")
|
|
}
|
|
|
|
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
|
|
pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "100"))
|
|
var total int64
|
|
q.Count(&total)
|
|
q.Offset((page - 1) * pageSize).Limit(pageSize).Order("id DESC").Find(&tasks)
|
|
|
|
views := make([]SiteTaskView, 0, len(tasks))
|
|
for _, task := range tasks {
|
|
if task.TaskType == "" {
|
|
inferred := model.SiteTaskType(siteprobe.InferTaskTypePublic(task))
|
|
h.db.Model(&task).Update("task_type", inferred)
|
|
task.TaskType = inferred
|
|
}
|
|
views = append(views, toSiteTaskView(task))
|
|
}
|
|
enrichSiteTaskViewsSSL(h.db, views)
|
|
enrichSiteTaskViewsAlert(h.db, views)
|
|
|
|
c.JSON(http.StatusOK, model.Response{Code: 200, Data: gin.H{"list": views, "total": total}})
|
|
}
|
|
|
|
// CreateTask 创建拨测任务
|
|
// POST /api/v1/site/tasks
|
|
func (h *SiteHandler) CreateTask(c *gin.Context) {
|
|
var raw map[string]interface{}
|
|
if err := c.ShouldBindJSON(&raw); err != nil {
|
|
c.JSON(http.StatusBadRequest, model.Response{Code: 400, Message: err.Error()})
|
|
return
|
|
}
|
|
task := parseCreateTaskRequest(raw)
|
|
if task.Name == "" || task.URL == "" {
|
|
c.JSON(http.StatusBadRequest, model.Response{Code: 400, Message: "名称和地址不能为空"})
|
|
return
|
|
}
|
|
task.Frequency = siteprobe.NormalizeProbeFrequency(string(task.TaskType), task.Frequency)
|
|
if task.Timeout <= 0 {
|
|
task.Timeout = 10
|
|
}
|
|
if err := h.db.Create(&task).Error; err != nil {
|
|
c.JSON(http.StatusInternalServerError, model.Response{Code: 500, Message: err.Error()})
|
|
return
|
|
}
|
|
if h.worker != nil {
|
|
go h.worker.RunTaskNow(task.ID)
|
|
}
|
|
c.JSON(http.StatusOK, model.Response{Code: 200, Data: toSiteTaskView(task), Message: "创建成功"})
|
|
}
|
|
|
|
// UpdateTask 更新拨测任务(启用/停用或配置变更)
|
|
// PUT /api/v1/site/tasks/:id
|
|
func (h *SiteHandler) UpdateTask(c *gin.Context) {
|
|
id := c.Param("id")
|
|
var task model.SiteTask
|
|
if err := h.db.First(&task, id).Error; err != nil {
|
|
c.JSON(http.StatusNotFound, model.Response{Code: 404, Message: "任务不存在"})
|
|
return
|
|
}
|
|
|
|
var raw map[string]interface{}
|
|
if err := c.ShouldBindJSON(&raw); err != nil {
|
|
c.JSON(http.StatusBadRequest, model.Response{Code: 400, Message: err.Error()})
|
|
return
|
|
}
|
|
|
|
updates := map[string]interface{}{}
|
|
if v, ok := raw["name"].(string); ok && strings.TrimSpace(v) != "" {
|
|
updates["name"] = strings.TrimSpace(v)
|
|
}
|
|
if v, ok := raw["url"].(string); ok && strings.TrimSpace(v) != "" {
|
|
updates["url"] = strings.TrimSpace(v)
|
|
}
|
|
if v, ok := raw["type"].(string); ok && v != "" {
|
|
t := strings.ToLower(v)
|
|
if t == "https" {
|
|
t = "http"
|
|
}
|
|
updates["task_type"] = t
|
|
}
|
|
if v, ok := raw["frequency_sec"].(float64); ok {
|
|
taskType := string(task.TaskType)
|
|
if tv, ok := updates["task_type"].(string); ok {
|
|
taskType = tv
|
|
}
|
|
updates["frequency"] = siteprobe.NormalizeProbeFrequency(taskType, int(v))
|
|
}
|
|
if v, ok := raw["timeout_sec"].(float64); ok {
|
|
updates["timeout"] = int(v)
|
|
}
|
|
if v, ok := raw["expected_status_code"].(float64); ok {
|
|
updates["expected_status"] = int(v)
|
|
}
|
|
|
|
// 内容监控配置。归一化规则一旦改动,旧基线就失去意义,
|
|
// 必须重置基线让下一轮自动重建,否则会立刻产生一条"变更"。 // Logan.Li
|
|
contentConfigChanged := false
|
|
if v, ok := raw["content_check_mode"].(string); ok {
|
|
mode := normalizeContentMode(v)
|
|
if mode != task.ContentCheckMode {
|
|
contentConfigChanged = true
|
|
if mode == "off" {
|
|
// 关闭内容监控时同时清空基线状态,避免残留待确认变更。
|
|
updates["current_baseline_id"] = 0
|
|
updates["pending_change_count"] = 0
|
|
}
|
|
}
|
|
updates["content_check_mode"] = mode
|
|
}
|
|
if v, ok := raw["content_paths"].(string); ok {
|
|
if v != task.ContentPaths {
|
|
contentConfigChanged = true
|
|
}
|
|
updates["content_paths"] = v
|
|
}
|
|
if v, ok := raw["content_ignore_rules"].(string); ok {
|
|
if v != task.ContentIgnoreRules {
|
|
contentConfigChanged = true
|
|
}
|
|
updates["content_ignore_rules"] = v
|
|
}
|
|
if v, ok := raw["content_min_length"].(float64); ok {
|
|
if int(v) != task.ContentMinLength {
|
|
contentConfigChanged = true
|
|
}
|
|
updates["content_min_length"] = int(v)
|
|
}
|
|
if v, ok := raw["content_confirm_times"].(float64); ok {
|
|
n := int(v)
|
|
if n <= 0 {
|
|
n = 2
|
|
}
|
|
updates["content_confirm_times"] = n
|
|
}
|
|
if v, ok := raw["content_sort_lines"].(bool); ok {
|
|
if v != task.ContentSortLines {
|
|
contentConfigChanged = true
|
|
}
|
|
updates["content_sort_lines"] = v
|
|
}
|
|
if contentConfigChanged {
|
|
updates["current_baseline_id"] = 0
|
|
updates["pending_change_count"] = 0
|
|
}
|
|
|
|
if v, ok := raw["enabled"].(bool); ok {
|
|
if v {
|
|
updates["status"] = model.SiteTaskStatusRunning
|
|
} else {
|
|
updates["status"] = model.SiteTaskStatusPaused
|
|
}
|
|
}
|
|
if len(updates) > 0 {
|
|
h.db.Model(&task).Updates(updates)
|
|
h.db.First(&task, id)
|
|
}
|
|
c.JSON(http.StatusOK, model.Response{Code: 200, Data: toSiteTaskView(task), Message: "更新成功"})
|
|
}
|
|
|
|
// deleteSiteTaskCascade 先删子表再删任务,避免外键约束 // Logan.Li
|
|
func (h *SiteHandler) deleteSiteTaskCascade(taskID uint) error {
|
|
return h.db.Transaction(func(tx *gorm.DB) error {
|
|
if err := tx.Where("task_id = ?", taskID).Delete(&model.SiteResult{}).Error; err != nil {
|
|
return err
|
|
}
|
|
if err := tx.Where("task_id = ?", taskID).Delete(&model.SiteAlert{}).Error; err != nil {
|
|
return err
|
|
}
|
|
// 内容监控的子表一并清理,避免任务删除后残留孤立基线/变更。 // Logan.Li
|
|
if err := tx.Where("task_id = ?", taskID).Delete(&model.SiteContentChange{}).Error; err != nil {
|
|
return err
|
|
}
|
|
if err := tx.Where("task_id = ?", taskID).Delete(&model.SiteBaseline{}).Error; err != nil {
|
|
return err
|
|
}
|
|
tx.Where("category = ? AND target_id = ?", model.MonitorAlertSite, taskID).Delete(&model.MonitorAlertRule{})
|
|
return tx.Delete(&model.SiteTask{}, taskID).Error
|
|
})
|
|
}
|
|
|
|
// DeleteTask 删除拨测任务
|
|
func (h *SiteHandler) DeleteTask(c *gin.Context) {
|
|
id, err := strconv.Atoi(c.Param("id"))
|
|
if err != nil {
|
|
c.JSON(http.StatusBadRequest, model.Response{Code: 400, Message: "无效 ID"})
|
|
return
|
|
}
|
|
if err := h.deleteSiteTaskCascade(uint(id)); err != nil {
|
|
c.JSON(http.StatusInternalServerError, model.Response{Code: 500, Message: err.Error()})
|
|
return
|
|
}
|
|
c.JSON(http.StatusOK, model.Response{Code: 200, Message: "删除成功"})
|
|
}
|
|
|
|
// BatchDeleteTasks 批量删除
|
|
func (h *SiteHandler) BatchDeleteTasks(c *gin.Context) {
|
|
var req model.IDsRequest
|
|
if err := c.ShouldBindJSON(&req); err != nil {
|
|
c.JSON(http.StatusBadRequest, model.Response{Code: 400, Message: err.Error()})
|
|
return
|
|
}
|
|
for _, tid := range req.IDs {
|
|
if err := h.deleteSiteTaskCascade(uint(tid)); err != nil {
|
|
c.JSON(http.StatusInternalServerError, model.Response{Code: 500, Message: err.Error()})
|
|
return
|
|
}
|
|
}
|
|
c.JSON(http.StatusOK, model.Response{Code: 200, Message: "批量删除成功"})
|
|
}
|
|
|
|
// ListResults 查询拨测结果
|
|
// GET /api/v1/site/results?task_id=N&limit=100
|
|
func (h *SiteHandler) ListResults(c *gin.Context) {
|
|
var results []model.SiteResult
|
|
q := h.db.Model(&model.SiteResult{})
|
|
if tid := c.Query("task_id"); tid != "" {
|
|
q = q.Where("task_id = ?", tid)
|
|
}
|
|
if h := c.Query("hours"); h != "" {
|
|
if hours, err := strconv.Atoi(h); err == nil && hours > 0 {
|
|
if hours > 168 {
|
|
hours = 168
|
|
}
|
|
since := time.Now().Add(-time.Duration(hours) * time.Hour)
|
|
q = q.Where("checked_at >= ?", since)
|
|
}
|
|
}
|
|
limit, _ := strconv.Atoi(c.DefaultQuery("limit", "500"))
|
|
if limit <= 0 {
|
|
limit = 500
|
|
}
|
|
if limit > 2000 {
|
|
limit = 2000
|
|
}
|
|
q.Order("checked_at DESC").Limit(limit).Find(&results)
|
|
if results == nil {
|
|
results = []model.SiteResult{}
|
|
}
|
|
c.JSON(http.StatusOK, model.Response{Code: 200, Data: gin.H{"list": results, "total": len(results)}})
|
|
}
|
|
|
|
// ProbeNow 立即触发拨测
|
|
// POST /api/v1/site/tasks/:id/probe
|
|
func (h *SiteHandler) ProbeNow(c *gin.Context) {
|
|
id, err := strconv.Atoi(c.Param("id"))
|
|
if err != nil {
|
|
c.JSON(http.StatusBadRequest, model.Response{Code: 400, Message: "无效 ID"})
|
|
return
|
|
}
|
|
if h.worker == nil {
|
|
c.JSON(http.StatusInternalServerError, model.Response{Code: 500, Message: "拨测引擎未启动"})
|
|
return
|
|
}
|
|
if err := h.worker.RunTaskNow(uint(id)); err != nil {
|
|
c.JSON(http.StatusNotFound, model.Response{Code: 404, Message: "任务不存在"})
|
|
return
|
|
}
|
|
var task model.SiteTask
|
|
h.db.First(&task, id)
|
|
view := toSiteTaskView(task)
|
|
enrichSiteTaskViewsSSL(h.db, []SiteTaskView{view})
|
|
enrichSiteTaskViewsAlert(h.db, []SiteTaskView{view})
|
|
c.JSON(http.StatusOK, model.Response{Code: 200, Data: view, Message: "拨测完成"})
|
|
}
|
|
|
|
// GetSiteStats 获取拨测概览统计
|
|
// GET /api/v1/site/stats
|
|
func (h *SiteHandler) GetSiteStats(c *gin.Context) {
|
|
var total, enabled, upCount, downCount int64
|
|
h.db.Model(&model.SiteTask{}).Count(&total)
|
|
h.db.Model(&model.SiteTask{}).Where("status = ?", model.SiteTaskStatusRunning).Count(&enabled)
|
|
h.db.Model(&model.SiteTask{}).Where("last_result = ?", "success").Count(&upCount)
|
|
h.db.Model(&model.SiteTask{}).Where("last_result IN ?", []string{"fail", "timeout"}).Count(&downCount)
|
|
|
|
var availability float64
|
|
since := time.Now().Add(-24 * time.Hour)
|
|
var successRecent, totalRecent int64
|
|
h.db.Model(&model.SiteResult{}).Where("checked_at >= ? AND status = ?", since, model.SiteResultSuccess).Count(&successRecent)
|
|
h.db.Model(&model.SiteResult{}).Where("checked_at >= ?", since).Count(&totalRecent)
|
|
if totalRecent > 0 {
|
|
availability = float64(successRecent) / float64(totalRecent)
|
|
}
|
|
|
|
c.JSON(http.StatusOK, model.Response{Code: 200, Data: gin.H{
|
|
"total_tasks": total,
|
|
"enabled_tasks": enabled,
|
|
"up_count": upCount,
|
|
"down_count": downCount,
|
|
"availability": availability,
|
|
"probe_count": totalRecent,
|
|
}})
|
|
}
|