需求:监控接口返回的数据本身是否变化(如 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
338 lines
17 KiB
Go
338 lines
17 KiB
Go
package model
|
||
|
||
import (
|
||
"time"
|
||
)
|
||
|
||
// ============================================
|
||
// SiteTask 拨测任务
|
||
// ============================================
|
||
|
||
// SiteTaskType 拨测任务类型
|
||
type SiteTaskType string
|
||
|
||
const (
|
||
SiteTaskTypeHTTP SiteTaskType = "http" // HTTP/HTTPS Web 拨测
|
||
SiteTaskTypeSSL SiteTaskType = "ssl" // SSL/TLS 证书拨测
|
||
SiteTaskTypeTCP SiteTaskType = "tcp" // TCP拨测
|
||
SiteTaskTypePing SiteTaskType = "ping" // ICMP Ping拨测
|
||
SiteTaskTypeDNS SiteTaskType = "dns" // DNS拨测
|
||
SiteTaskTypeUDP SiteTaskType = "udp" // UDP拨测
|
||
SiteTaskTypeFTP SiteTaskType = "ftp" // FTP拨测
|
||
SiteTaskTypeSMTP SiteTaskType = "smtp" // SMTP拨测
|
||
SiteTaskTypeTraceroute SiteTaskType = "traceroute" // 路由追踪
|
||
)
|
||
|
||
// SiteTaskMethod HTTP请求方法
|
||
type SiteTaskMethod string
|
||
|
||
const (
|
||
SiteTaskMethodGET SiteTaskMethod = "GET"
|
||
SiteTaskMethodPOST SiteTaskMethod = "POST"
|
||
SiteTaskMethodHEAD SiteTaskMethod = "HEAD"
|
||
)
|
||
|
||
// SiteTaskStatus 拨测任务状态
|
||
type SiteTaskStatus string
|
||
|
||
const (
|
||
SiteTaskStatusRunning SiteTaskStatus = "running" // 运行中
|
||
SiteTaskStatusPaused SiteTaskStatus = "paused" // 已暂停
|
||
SiteTaskStatusError SiteTaskStatus = "error" // 异常
|
||
)
|
||
|
||
// SiteTask 站点拨测任务表
|
||
// 定义对网站、服务等进行主动拨测的任务配置
|
||
type SiteTask struct {
|
||
ID uint `gorm:"primaryKey;autoIncrement;comment:主键ID" json:"id"`
|
||
Name string `gorm:"type:varchar(128);not null;comment:任务名称" json:"name"`
|
||
URL string `gorm:"type:varchar(512);not null;comment:拨测URL/地址" json:"url"`
|
||
TaskType SiteTaskType `gorm:"type:varchar(16);not null;comment:拨测类型" json:"task_type"`
|
||
Method SiteTaskMethod `gorm:"type:varchar(8);default:'GET';comment:HTTP方法" json:"method"`
|
||
// HTTP 拨测配置
|
||
Headers string `gorm:"type:text;comment:请求头JSON" json:"headers,omitempty"`
|
||
Body string `gorm:"type:text;comment:请求体" json:"body,omitempty"`
|
||
Timeout int `gorm:"default:10;comment:超时时间(秒)" json:"timeout"`
|
||
FollowRedirect bool `gorm:"default:true;comment:是否跟随重定向" json:"follow_redirect"`
|
||
VerifySSL bool `gorm:"default:true;comment:是否验证SSL证书" json:"verify_ssl"`
|
||
// 期望响应配置
|
||
ExpectedStatus int `gorm:"default:200;comment:期望状态码" json:"expected_status"`
|
||
ExpectedContent string `gorm:"type:text;comment:期望响应内容(支持正则)" json:"expected_content,omitempty"`
|
||
ExpectedLength int `gorm:"default:0;comment:期望响应长度(0表示不检查)" json:"expected_length"`
|
||
// 执行配置
|
||
Frequency int `gorm:"default:60;comment:拨测频率(秒)" json:"frequency"`
|
||
Nodes string `gorm:"type:varchar(256);comment:执行节点列表JSON" json:"nodes,omitempty"`
|
||
// DNS 配置
|
||
DNSResolver string `gorm:"type:varchar(64);comment:DNS解析服务器" json:"dns_resolver,omitempty"`
|
||
// 状态
|
||
Status SiteTaskStatus `gorm:"type:varchar(16);default:'running';comment:任务状态" json:"status"`
|
||
LastResult string `gorm:"type:varchar(16);comment:最近结果: success/fail" json:"last_result,omitempty"`
|
||
LastCheckAt *time.Time `gorm:"comment:最近检查时间" json:"last_check_at,omitempty"`
|
||
SuccessRate float64 `gorm:"type:decimal(5,2);default:100.00;comment:成功率(%)" json:"success_rate"`
|
||
AvgResponse float64 `gorm:"comment:平均响应时间(ms)" json:"avg_response,omitempty"`
|
||
Description string `gorm:"type:varchar(256);comment:描述" json:"description,omitempty"`
|
||
CreatedBy uint `gorm:"index;comment:创建人ID" json:"created_by"`
|
||
CreatedAt time.Time `gorm:"not null;comment:创建时间" json:"created_at"`
|
||
UpdatedAt time.Time `gorm:"not null;comment:更新时间" json:"updated_at"`
|
||
DeletedAt *time.Time `gorm:"index;comment:删除时间" json:"deleted_at,omitempty"`
|
||
|
||
// ── 内容监控(Content Monitor)────────────────────────────────
|
||
// 区别于"可用性拨测":服务活着但返回内容变了,这里负责发现。 // Logan.Li
|
||
ContentCheckMode string `gorm:"type:varchar(8);default:'off';comment:内容检查模式 off/text/json" json:"content_check_mode"`
|
||
// ContentPaths JSON 模式要参与比对字段的 JSONPath,如 ["$.data.status","$.data.ips"]
|
||
ContentPaths string `gorm:"type:text;comment:JSONPath字段列表(JSON数组)" json:"content_paths,omitempty"`
|
||
// ContentIgnoreRules 忽略规则:文本按正则替换为空,避免时间戳/nonce 造成误报
|
||
ContentIgnoreRules string `gorm:"type:text;comment:忽略规则(JSON数组,正则)" json:"content_ignore_rules,omitempty"`
|
||
// ContentMinLength 小于该长度判为"采集异常"而非"内容变更",防止错误页误报
|
||
ContentMinLength int `gorm:"default:0;comment:最小有效长度(字节)" json:"content_min_length"`
|
||
// ContentConfirmTimes 连续 N 次不一致才判定变更,抑制抖动
|
||
ContentConfirmTimes int `gorm:"default:2;comment:连续确认次数" json:"content_confirm_times"`
|
||
// ContentSortLines 纯文本是否排序后比对(顺序无关的清单类接口应开启)
|
||
ContentSortLines bool `gorm:"default:false;comment:文本排序后比对" json:"content_sort_lines"`
|
||
// CurrentBaselineID 当前生效的基线版本
|
||
CurrentBaselineID uint `gorm:"default:0;comment:当前基线ID" json:"current_baseline_id"`
|
||
// PendingChangeCount 待确认的变更次数(用于防抖计数)
|
||
PendingChangeCount int `gorm:"default:0;comment:连续变更计数" json:"pending_change_count"`
|
||
|
||
// 关联关系
|
||
Results []SiteResult `gorm:"foreignKey:TaskID" json:"results,omitempty"`
|
||
Alerts []SiteAlert `gorm:"foreignKey:TaskID" json:"alerts,omitempty"`
|
||
}
|
||
|
||
// TableName 指定表名
|
||
func (SiteTask) TableName() string {
|
||
return "monitor_site_tasks"
|
||
}
|
||
|
||
// ============================================
|
||
// SiteResult 拨测结果
|
||
// ============================================
|
||
|
||
// SiteResultStatus 拨测结果状态
|
||
type SiteResultStatus string
|
||
|
||
const (
|
||
SiteResultSuccess SiteResultStatus = "success" // 成功
|
||
SiteResultFail SiteResultStatus = "fail" // 失败
|
||
SiteResultTimeout SiteResultStatus = "timeout" // 超时
|
||
)
|
||
|
||
// SiteResult 拨测结果表
|
||
// 存储每次拨测的详细结果数据
|
||
type SiteResult struct {
|
||
ID uint `gorm:"primaryKey;autoIncrement;comment:主键ID" json:"id"`
|
||
TaskID uint `gorm:"not null;index:idx_task_time;comment:任务ID" json:"task_id"`
|
||
Status SiteResultStatus `gorm:"type:varchar(16);not null;comment:结果状态" json:"status"`
|
||
HTTPStatusCode int `gorm:"comment:HTTP状态码" json:"http_status_code,omitempty"`
|
||
// 时间指标
|
||
DNSTime float64 `gorm:"comment:DNS解析时间(ms)" json:"dns_time,omitempty"`
|
||
ConnectTime float64 `gorm:"comment:连接时间(ms)" json:"connect_time,omitempty"`
|
||
TLSTime float64 `gorm:"comment:TLS握手时间(ms)" json:"tls_time,omitempty"`
|
||
TTFB float64 `gorm:"comment:首字节时间(ms)" json:"ttfb,omitempty"`
|
||
ResponseTime float64 `gorm:"comment:响应时间(ms)" json:"response_time,omitempty"`
|
||
TotalTime float64 `gorm:"not null;comment:总耗时(ms)" json:"total_time"`
|
||
// 响应信息
|
||
ResponseSize int `gorm:"comment:响应大小(bytes)" json:"response_size,omitempty"`
|
||
ResponseBody string `gorm:"type:text;comment:响应内容摘要" json:"response_body,omitempty"`
|
||
// 网络信息
|
||
RemoteIP string `gorm:"type:varchar(64);comment:远端IP" json:"remote_ip,omitempty"`
|
||
LocalNode string `gorm:"type:varchar(64);comment:执行节点" json:"local_node,omitempty"`
|
||
// 错误信息
|
||
ErrorMessage string `gorm:"type:text;comment:错误信息" json:"error_message,omitempty"`
|
||
ErrorDetail string `gorm:"type:text;comment:错误详情" json:"error_detail,omitempty"`
|
||
// SSL 证书信息
|
||
SSLCertExpiry *time.Time `gorm:"comment:SSL证书过期时间" json:"ssl_cert_expiry,omitempty"`
|
||
SSLCertIssuer string `gorm:"type:varchar(256);comment:SSL证书签发者" json:"ssl_cert_issuer,omitempty"`
|
||
SSLCertSubject string `gorm:"type:varchar(256);comment:SSL证书主题" json:"ssl_cert_subject,omitempty"`
|
||
SSLDaysRemaining int `gorm:"comment:SSL证书剩余天数" json:"ssl_days_remaining,omitempty"`
|
||
// 检查内容匹配
|
||
ContentMatch bool `gorm:"comment:内容匹配结果" json:"content_match,omitempty"`
|
||
// 时间戳
|
||
CheckedAt time.Time `gorm:"not null;index:idx_task_time;comment:拨测时间" json:"checked_at"`
|
||
CreatedAt time.Time `gorm:"not null;comment:创建时间" json:"created_at"`
|
||
|
||
// 关联关系
|
||
Task *SiteTask `gorm:"foreignKey:TaskID" json:"task,omitempty"`
|
||
}
|
||
|
||
// TableName 指定表名
|
||
func (SiteResult) TableName() string {
|
||
return "monitor_site_results"
|
||
}
|
||
|
||
// ============================================
|
||
// SiteAlert 拨测告警
|
||
// ============================================
|
||
|
||
// SiteAlertStatus 拨测告警状态
|
||
type SiteAlertStatus string
|
||
|
||
const (
|
||
SiteAlertStatusActive SiteAlertStatus = "active" // 活跃
|
||
SiteAlertStatusResolved SiteAlertStatus = "resolved" // 已恢复
|
||
SiteAlertStatusMuted SiteAlertStatus = "muted" // 已静默
|
||
)
|
||
|
||
// SiteAlertSeverity 拨测告警级别
|
||
type SiteAlertSeverity string
|
||
|
||
const (
|
||
SiteAlertCritical SiteAlertSeverity = "critical" // 严重
|
||
SiteAlertWarning SiteAlertSeverity = "warning" // 警告
|
||
SiteAlertInfo SiteAlertSeverity = "info" // 通知
|
||
)
|
||
|
||
// SiteAlert 拨测告警表
|
||
// 记录拨测任务触发的告警事件
|
||
type SiteAlert struct {
|
||
ID uint `gorm:"primaryKey;autoIncrement;comment:主键ID" json:"id"`
|
||
TaskID uint `gorm:"not null;index;comment:任务ID" json:"task_id"`
|
||
Severity SiteAlertSeverity `gorm:"type:varchar(16);not null;comment:告警级别" json:"severity"`
|
||
Status SiteAlertStatus `gorm:"type:varchar(16);default:'active';index;comment:告警状态" json:"status"`
|
||
Title string `gorm:"type:varchar(256);not null;comment:告警标题" json:"title"`
|
||
Message string `gorm:"type:text;comment:告警详情" json:"message,omitempty"`
|
||
// 触发条件
|
||
TriggerType string `gorm:"type:varchar(32);comment:触发类型: timeout/status_error/response_time/ssl_expiry" json:"trigger_type"`
|
||
TriggerValue string `gorm:"type:varchar(128);comment:触发值" json:"trigger_value,omitempty"`
|
||
Threshold float64 `gorm:"comment:阈值" json:"threshold,omitempty"`
|
||
// 响应信息
|
||
ResponseTime float64 `gorm:"comment:响应时间(ms)" json:"response_time,omitempty"`
|
||
HTTPStatus int `gorm:"comment:HTTP状态码" json:"http_status,omitempty"`
|
||
ErrorMessage string `gorm:"type:text;comment:错误信息" json:"error_message,omitempty"`
|
||
// 状态变化
|
||
StartedAt time.Time `gorm:"not null;comment:告警开始时间" json:"started_at"`
|
||
ResolvedAt *time.Time `gorm:"comment:告警恢复时间" json:"resolved_at,omitempty"`
|
||
Duration int `gorm:"comment:持续时间(秒)" json:"duration,omitempty"`
|
||
NotifyCount int `gorm:"default:0;comment:通知次数" json:"notify_count"`
|
||
LastNotifyAt *time.Time `gorm:"comment:最近通知时间" json:"last_notify_at,omitempty"`
|
||
Acknowledged bool `gorm:"default:false;comment:是否已确认" json:"acknowledged"`
|
||
AcknowledgedBy uint `gorm:"comment:确认人ID" json:"acknowledged_by,omitempty"`
|
||
AcknowledgedAt *time.Time `gorm:"comment:确认时间" json:"acknowledged_at,omitempty"`
|
||
CreatedAt time.Time `gorm:"not null;comment:创建时间" json:"created_at"`
|
||
UpdatedAt time.Time `gorm:"not null;comment:更新时间" json:"updated_at"`
|
||
|
||
// 关联关系
|
||
Task *SiteTask `gorm:"foreignKey:TaskID" json:"task,omitempty"`
|
||
}
|
||
|
||
// TableName 指定表名
|
||
func (SiteAlert) TableName() string {
|
||
return "monitor_site_alerts"
|
||
}
|
||
|
||
// SiteTaskCreateRequest 拨测任务创建请求
|
||
type SiteTaskCreateRequest struct {
|
||
Name string `json:"name" binding:"required,max=128"`
|
||
URL string `json:"url" binding:"required,max=512,url"`
|
||
TaskType SiteTaskType `json:"task_type" binding:"required"`
|
||
Method SiteTaskMethod `json:"method"`
|
||
Headers string `json:"headers"`
|
||
Body string `json:"body"`
|
||
Timeout int `json:"timeout"`
|
||
FollowRedirect bool `json:"follow_redirect"`
|
||
VerifySSL bool `json:"verify_ssl"`
|
||
ExpectedStatus int `json:"expected_status"`
|
||
ExpectedContent string `json:"expected_content"`
|
||
ExpectedLength int `json:"expected_length"`
|
||
Frequency int `json:"frequency"`
|
||
Nodes []string `json:"nodes"`
|
||
DNSResolver string `json:"dns_resolver"`
|
||
Description string `json:"description"`
|
||
}
|
||
|
||
// SiteTaskUpdateRequest 拨测任务更新请求
|
||
type SiteTaskUpdateRequest struct {
|
||
Name string `json:"name" binding:"max=128"`
|
||
URL string `json:"url" binding:"max=512,url"`
|
||
Method SiteTaskMethod `json:"method"`
|
||
Headers string `json:"headers"`
|
||
Body string `json:"body"`
|
||
Timeout int `json:"timeout"`
|
||
FollowRedirect bool `json:"follow_redirect"`
|
||
VerifySSL bool `json:"verify_ssl"`
|
||
ExpectedStatus int `json:"expected_status"`
|
||
ExpectedContent string `json:"expected_content"`
|
||
Frequency int `json:"frequency"`
|
||
Nodes []string `json:"nodes"`
|
||
Status SiteTaskStatus `json:"status"`
|
||
Description string `json:"description"`
|
||
}
|
||
|
||
// SiteTaskStatistics 拨测任务统计
|
||
type SiteTaskStatistics struct {
|
||
TaskID uint `json:"task_id"`
|
||
TotalChecks int64 `json:"total_checks"`
|
||
SuccessCount int64 `json:"success_count"`
|
||
FailCount int64 `json:"fail_count"`
|
||
TimeoutCount int64 `json:"timeout_count"`
|
||
SuccessRate float64 `json:"success_rate"`
|
||
AvgResponseTime float64 `json:"avg_response_time"`
|
||
MinResponseTime float64 `json:"min_response_time"`
|
||
MaxResponseTime float64 `json:"max_response_time"`
|
||
Last24hUptime float64 `json:"last_24h_uptime"`
|
||
Last7dUptime float64 `json:"last_7d_uptime"`
|
||
Last30dUptime float64 `json:"last_30d_uptime"`
|
||
}
|
||
|
||
// SiteResultQuery 拨测结果查询参数
|
||
type SiteResultQuery struct {
|
||
TaskID uint `json:"task_id" form:"task_id"`
|
||
Status string `json:"status" form:"status"`
|
||
StartTime int64 `json:"start_time" form:"start_time"`
|
||
EndTime int64 `json:"end_time" form:"end_time"`
|
||
Limit int `json:"limit" form:"limit"`
|
||
Offset int `json:"offset" form:"offset"`
|
||
}
|
||
|
||
// ============================================
|
||
// 内容监控:基线版本 + 变更记录
|
||
// ============================================
|
||
|
||
// SiteBaseline 内容基线(一个任务的多版本快照)。
|
||
//
|
||
// 首次采集自动建立 v1(source=auto),之后每次"接受新基线"新增一版(source=manual)。
|
||
// 保留完整规范化内容,用于并排 diff 与回溯。 // Logan.Li
|
||
type SiteBaseline struct {
|
||
ID uint `gorm:"primaryKey;autoIncrement" json:"id"`
|
||
TaskID uint `gorm:"not null;index:idx_baseline_task;comment:任务ID" json:"task_id"`
|
||
Version int `gorm:"not null;comment:版本号(从1递增)" json:"version"`
|
||
ContentHash string `gorm:"type:varchar(64);not null;comment:规范化内容sha256" json:"content_hash"`
|
||
ContentText string `gorm:"type:longtext;comment:规范化后的内容快照" json:"content_text,omitempty"`
|
||
Source string `gorm:"type:varchar(16);default:'auto';comment:来源 auto/manual" json:"source"`
|
||
AcceptedBy string `gorm:"type:varchar(128);comment:确认人" json:"accepted_by"`
|
||
Note string `gorm:"type:varchar(512);comment:备注" json:"note"`
|
||
CreatedAt time.Time `gorm:"not null;comment:创建时间" json:"created_at"`
|
||
}
|
||
|
||
// TableName 指定表名 // Logan.Li
|
||
func (SiteBaseline) TableName() string { return "monitor_site_baselines" }
|
||
|
||
// SiteContentChange 内容变更记录。
|
||
//
|
||
// Status 流转:pending(已发现,待处理)→ accepted(接受为新基线)/ ignored(误报忽略)。
|
||
// DiffPayload 存结构化差异,前端并排视图直接渲染。 // Logan.Li
|
||
type SiteContentChange struct {
|
||
ID uint `gorm:"primaryKey;autoIncrement" json:"id"`
|
||
TaskID uint `gorm:"not null;index:idx_change_task" json:"task_id"`
|
||
FromBaselineID uint `gorm:"not null;comment:变更前基线" json:"from_baseline_id"`
|
||
FromVersion int `gorm:"comment:变更前版本号" json:"from_version"`
|
||
ContentHash string `gorm:"type:varchar(64);comment:新内容hash" json:"content_hash"`
|
||
ContentText string `gorm:"type:longtext;comment:新内容快照" json:"content_text,omitempty"`
|
||
DiffType string `gorm:"type:varchar(16);comment:text_line/json_field" json:"diff_type"`
|
||
DiffPayload string `gorm:"type:longtext;comment:结构化diff(JSON)" json:"diff_payload,omitempty"`
|
||
Status string `gorm:"type:varchar(16);default:'pending';index;comment:状态" json:"status"`
|
||
HandledBy string `gorm:"type:varchar(128);comment:处理人" json:"handled_by,omitempty"`
|
||
DetectedAt time.Time `gorm:"not null;comment:发现时间" json:"detected_at"`
|
||
HandledAt *time.Time `json:"handled_at,omitempty"`
|
||
}
|
||
|
||
// TableName 指定表名 // Logan.Li
|
||
func (SiteContentChange) TableName() string { return "monitor_site_content_changes" }
|
||
|
||
// 内容变更状态常量 // Logan.Li
|
||
const (
|
||
ContentChangePending = "pending"
|
||
ContentChangeAccepted = "accepted"
|
||
ContentChangeIgnored = "ignored"
|
||
)
|