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

297 lines
9.2 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.
// Package migrations 提供仅由命令行显式触发的版本化数据库迁移。 // Logan.Li
package migrations
import (
"embed"
"errors"
"fmt"
"io/fs"
"sort"
"strings"
"time"
"ezops/model"
"ezops/pkg/database"
"gorm.io/gorm"
)
const (
// BaselineVersion 标识当前模型集合的初始基线,不应在已有库上自动执行。 // Logan.Li
BaselineVersion = "0000_baseline_schema"
migrationTable = "sys_schema_migrations"
)
//go:embed *.sql
var migrationFiles embed.FS
// Migration 是一个不可变、更名即视为兼容性破坏的数据库变更单元。 // Logan.Li
type Migration struct {
Version string
Description string
Apply func(*gorm.DB) error
}
// StatusRow 描述一个迁移版本的执行状态。 // Logan.Li
type StatusRow struct {
Version string
Description string
Applied bool
AppliedAt *time.Time
}
// All 返回按版本排序的所有迁移。新迁移只能追加,禁止修改已发布版本。 // Logan.Li
func All() []Migration {
return []Migration{
{
Version: BaselineVersion,
Description: "新数据库初始结构基线",
Apply: database.BootstrapSchema,
},
{
Version: "0001_agent_credentials",
Description: "Agent 主机绑定凭证",
Apply: func(db *gorm.DB) error {
return executeDialectSQL(db, "0001_agent_credentials")
},
},
{
Version: "0002_legacy_schema_patches",
Description: "历史库字段兼容补丁",
Apply: database.ApplyLegacySchemaPatches,
},
{
Version: "0003_billing_settings",
Description: "云账单汇率设置",
Apply: func(db *gorm.DB) error {
return executeDialectSQL(db, "0003_billing_settings")
},
},
{
Version: "0004_metric_aggregate_unique",
Description: "指标聚合表唯一约束(upsert 生效)",
Apply: func(db *gorm.DB) error {
// 新库基线已由模型自带 uniq_metric_agg(uniqueIndex 标签)建出该索引,
// 此时必须跳过:否则 CREATE UNIQUE INDEX 会以 1061 重复键名中断整个 migrate up,
// 导致后面的迁移(如 0005_agent_settings)永远建不出来。
// 只有老库(历史 idx_agg 为非唯一索引)才需要去重 + 重建唯一索引。 // Logan.Li
exists, err := database.IndexExists(db, "monitor_metric_aggregates", "uniq_metric_agg")
if err != nil {
return err
}
if exists {
return nil
}
return executeDialectSQL(db, "0004_metric_aggregate_unique")
},
},
{
Version: "0005_agent_settings",
Description: "Agent 采集策略(可下发频率)",
Apply: func(db *gorm.DB) error {
return executeDialectSQL(db, "0005_agent_settings")
},
},
{
Version: "0006_site_content_monitor",
Description: "站点拨测内容监控(基线 + 变更)",
Apply: func(db *gorm.DB) error {
// 用 AutoMigrate 而非静态 SQL:它是幂等的。
// 空库上 0000_baseline_schema 已经建好这些列与表,静态 ALTER 会因
// duplicate column 失败;旧库则正好在这里补齐缺失的列/表。 // Logan.Li
return db.AutoMigrate(
&model.SiteTask{},
&model.SiteBaseline{},
&model.SiteContentChange{},
)
},
},
}
}
// Up 执行尚未应用的迁移。已有业务表但未完成基线登记时拒绝执行,防止误触发结构重建。 // Logan.Li
func Up(db *gorm.DB) ([]string, error) {
// 旧业务库缺少迁移记录表时必须零写入拒绝,避免误操作留下半初始化痕迹。 // Logan.Li
if hasExistingApplicationSchema(db) && !db.Migrator().HasTable(migrationTable) {
return nil, fmt.Errorf("检测到已有业务库但缺少 %s;请在备份与维护窗口确认后执行 `aiops migrate baseline`", migrationTable)
}
if err := ensureMigrationTable(db); err != nil {
return nil, err
}
applied, err := appliedVersions(db)
if err != nil {
return nil, err
}
if _, baselineApplied := applied[BaselineVersion]; !baselineApplied && hasExistingApplicationSchema(db) {
return nil, fmt.Errorf("检测到已有业务库但未登记 %s;请在备份与维护窗口确认后执行 `aiops migrate baseline`", BaselineVersion)
}
completed := make([]string, 0)
for _, migration := range All() {
if _, alreadyApplied := applied[migration.Version]; alreadyApplied {
continue
}
if err := migration.Apply(db); err != nil {
return completed, fmt.Errorf("迁移 %s(%s)失败: %w", migration.Version, migration.Description, err)
}
if err := recordApplied(db, migration.Version); err != nil {
return completed, fmt.Errorf("登记迁移 %s 失败: %w", migration.Version, err)
}
completed = append(completed, migration.Version)
}
return completed, nil
}
// Baseline 仅为已经存在且已人工核验的旧数据库写入初始基线记录,不修改业务表。 // Logan.Li
func Baseline(db *gorm.DB) error {
if err := ensureMigrationTable(db); err != nil {
return err
}
applied, err := appliedVersions(db)
if err != nil {
return err
}
if _, baselineApplied := applied[BaselineVersion]; baselineApplied {
return nil
}
if !hasExistingApplicationSchema(db) {
return errors.New("数据库为空,不能执行 baseline;请改用 `aiops migrate up`")
}
if err := validateLegacyBaseline(db); err != nil {
return err
}
return recordApplied(db, BaselineVersion)
}
// Status 返回每个已知迁移的状态;只读查询,记录表不存在时全部显示 pending。 // Logan.Li
func Status(db *gorm.DB) ([]StatusRow, error) {
applied := map[string]time.Time{}
if db.Migrator().HasTable(migrationTable) {
var err error
applied, err = appliedVersions(db)
if err != nil {
return nil, err
}
}
rows := make([]StatusRow, 0, len(All()))
for _, migration := range All() {
row := StatusRow{
Version: migration.Version,
Description: migration.Description,
}
if appliedAt, ok := applied[migration.Version]; ok {
row.Applied = true
row.AppliedAt = &appliedAt
}
rows = append(rows, row)
}
return rows, nil
}
func ensureMigrationTable(db *gorm.DB) error {
sql := `CREATE TABLE IF NOT EXISTS sys_schema_migrations (
version VARCHAR(128) PRIMARY KEY,
applied_at DATETIME NOT NULL
)`
if db.Dialector.Name() == "mysql" {
sql = `CREATE TABLE IF NOT EXISTS sys_schema_migrations (
version VARCHAR(128) PRIMARY KEY,
applied_at DATETIME NOT NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4`
} else if db.Dialector.Name() == "postgres" {
sql = `CREATE TABLE IF NOT EXISTS sys_schema_migrations (
version VARCHAR(128) PRIMARY KEY,
applied_at TIMESTAMP NOT NULL
)`
}
if err := db.Exec(sql).Error; err != nil {
return fmt.Errorf("创建迁移记录表失败: %w", err)
}
return nil
}
func appliedVersions(db *gorm.DB) (map[string]time.Time, error) {
type appliedMigration struct {
Version string
AppliedAt time.Time
}
var records []appliedMigration
if err := db.Table(migrationTable).Select("version, applied_at").Find(&records).Error; err != nil {
return nil, fmt.Errorf("读取迁移记录失败: %w", err)
}
result := make(map[string]time.Time, len(records))
for _, record := range records {
result[record.Version] = record.AppliedAt
}
return result, nil
}
func recordApplied(db *gorm.DB, version string) error {
return db.Exec("INSERT INTO sys_schema_migrations (version, applied_at) VALUES (?, ?)", version, time.Now()).Error
}
func hasExistingApplicationSchema(db *gorm.DB) bool {
return db.Migrator().HasTable("iam_users") || db.Migrator().HasTable("asset_servers")
}
func validateLegacyBaseline(db *gorm.DB) error {
requiredTables := []string{"iam_users", "iam_roles", "asset_servers", "deploy_pipelines", "monitor_agent_reports"}
for _, table := range requiredTables {
if !db.Migrator().HasTable(table) {
return fmt.Errorf("旧库缺少基线校验表 %s;请先恢复完整数据库或编写专项迁移", table)
}
}
return nil
}
func executeDialectSQL(db *gorm.DB, prefix string) error {
files, err := fs.Glob(migrationFiles, prefix+".*.sql")
if err != nil {
return fmt.Errorf("查找迁移脚本失败: %w", err)
}
sort.Strings(files)
dialect := db.Dialector.Name()
fileName := prefix + "." + dialect + ".sql"
if !contains(files, fileName) {
return fmt.Errorf("迁移 %s 不支持数据库驱动 %s", prefix, dialect)
}
content, err := migrationFiles.ReadFile(fileName)
if err != nil {
return fmt.Errorf("读取迁移脚本失败: %w", err)
}
statements := splitStatements(string(content))
for _, statement := range statements {
if err := db.Exec(statement).Error; err != nil {
return fmt.Errorf("执行 %s 失败: %w", fileName, err)
}
}
return nil
}
func splitStatements(content string) []string {
parts := strings.Split(content, ";")
statements := make([]string, 0, len(parts))
for _, part := range parts {
withoutComments := make([]string, 0)
for _, line := range strings.Split(part, "\n") {
line = strings.TrimSpace(line)
if line != "" && !strings.HasPrefix(line, "--") {
withoutComments = append(withoutComments, line)
}
}
if statement := strings.TrimSpace(strings.Join(withoutComments, "\n")); statement != "" {
statements = append(statements, statement)
}
}
return statements
}
func contains(items []string, expected string) bool {
for _, item := range items {
if item == expected {
return true
}
}
return false
}