package storage import ( "encoding/json" "fmt" "log" "os" "strings" "sync" "time" "gorm.io/datatypes" "gorm.io/driver/mysql" "gorm.io/gorm" "gorm.io/gorm/logger" "mengyaping-backend/config" "mengyaping-backend/models" ) // Storage 基于 MySQL 的持久化(站点 / 分类 / 检测记录) type Storage struct { db *gorm.DB } var ( store *Storage once sync.Once ) // GetStorage 初始化数据库连接并迁移表结构 func GetStorage() *Storage { once.Do(func() { cfg := config.GetConfig() dsn := cfg.DatabaseDSN() gormLog := logger.Default.LogMode(logger.Warn) if os.Getenv("DB_DEBUG") == "1" { gormLog = logger.Default.LogMode(logger.Info) } db, err := gorm.Open(mysql.Open(dsn), &gorm.Config{ Logger: gormLog, }) if err != nil { log.Fatalf("MySQL 连接失败: %v(请检查 DB_* / DB_DSN 环境变量)", err) } sqlDB, err := db.DB() if err != nil { log.Fatalf("MySQL sqlDB: %v", err) } sqlDB.SetMaxIdleConns(8) sqlDB.SetMaxOpenConns(40) sqlDB.SetConnMaxLifetime(time.Hour) store = &Storage{db: db} if err := store.migrate(); err != nil { log.Fatalf("数据库迁移失败: %v", err) } store.seedAdminTokenKV() store.loadAndSyncAppConfig() store.seedDefaultGroups() log.Println("MySQL 存储已就绪: " + cfg.Database.Database + "@" + cfg.Database.Host) }) return store } func (s *Storage) migrate() error { err := s.db.AutoMigrate( &MonitorGroup{}, &MonitorWebsite{}, &MonitorWebsiteURL{}, &MonitorWebsiteGroup{}, &MonitorProbeLatest{}, &MonitorProbeHour{}, &MonitorProbeDay{}, &MonitorKV{}, ) if err != nil { return err } // 旧版逐条 monitor_checks 表体积极大,已弃用 if s.db.Migrator().HasTable("monitor_checks") { if err := s.db.Migrator().DropTable("monitor_checks"); err != nil { log.Printf("删除旧表 monitor_checks: %v(可手工 DROP)", err) } else { log.Println("已删除旧表 monitor_checks") } } return nil } func (s *Storage) seedDefaultGroups() { var n int64 s.db.Model(&MonitorGroup{}).Count(&n) if n > 0 { return } order := 0 for _, g := range models.DefaultGroups { order++ s.db.Create(&MonitorGroup{ ID: g.ID, Name: g.Name, SortOrder: order, }) } log.Println("已写入默认分类 monitor_groups") } func ipsToJSON(ips []string) []byte { if ips == nil { ips = []string{} } b, err := json.Marshal(ips) if err != nil { return []byte("[]") } return b } func jsonToIPs(raw []byte) []string { if len(raw) == 0 { return nil } var ips []string if err := json.Unmarshal(raw, &ips); err != nil { return nil } return ips } func rowToWebsite(w *MonitorWebsite) models.Website { groups := make([]string, 0, len(w.GroupLinks)) for _, l := range w.GroupLinks { groups = append(groups, l.GroupID) } if len(groups) == 0 && w.LegacyGroup != "" { groups = []string{w.LegacyGroup} } urls := make([]models.URLInfo, 0, len(w.URLs)) for _, u := range w.URLs { urls = append(urls, models.URLInfo{ ID: u.URLID, URL: u.URL, Remark: u.Remark, }) } return models.Website{ ID: w.ID, Name: w.Name, Groups: groups, Group: "", URLs: urls, IPAddresses: jsonToIPs(w.IPAddresses), Favicon: w.Favicon, Title: w.Title, CreatedAt: w.CreatedAt, UpdatedAt: w.UpdatedAt, } } func websiteToRow(w *models.Website) *MonitorWebsite { return &MonitorWebsite{ ID: w.ID, Name: w.Name, LegacyGroup: "", Favicon: w.Favicon, Title: w.Title, IPAddresses: datatypes.JSON(ipsToJSON(w.IPAddresses)), CreatedAt: w.CreatedAt, UpdatedAt: w.UpdatedAt, } } func (s *Storage) listWebsiteModels() ([]models.Website, error) { var rows []MonitorWebsite if err := s.db.Preload("URLs", func(db *gorm.DB) *gorm.DB { return db.Order("sort_order ASC, id ASC") }).Preload("GroupLinks").Order("created_at ASC").Find(&rows).Error; err != nil { return nil, err } out := make([]models.Website, len(rows)) for i := range rows { out[i] = rowToWebsite(&rows[i]) } return out, nil } // GetWebsites 获取所有网站 func (s *Storage) GetWebsites() []models.Website { list, err := s.listWebsiteModels() if err != nil { log.Printf("GetWebsites: %v", err) return nil } return list } // GetWebsite 获取单个网站 func (s *Storage) GetWebsite(id string) *models.Website { var w MonitorWebsite if err := s.db.Preload("URLs", func(db *gorm.DB) *gorm.DB { return db.Order("sort_order ASC, id ASC") }).Preload("GroupLinks").First(&w, "id = ?", id).Error; err != nil { return nil } m := rowToWebsite(&w) return &m } func (s *Storage) persistWebsiteFull(w models.Website) error { return s.db.Transaction(func(tx *gorm.DB) error { row := websiteToRow(&w) if err := tx.Save(row).Error; err != nil { return err } if err := tx.Where("website_id = ?", w.ID).Delete(&MonitorWebsiteURL{}).Error; err != nil { return err } if err := tx.Where("website_id = ?", w.ID).Delete(&MonitorWebsiteGroup{}).Error; err != nil { return err } for i, u := range w.URLs { if err := tx.Create(&MonitorWebsiteURL{ WebsiteID: w.ID, URLID: u.ID, URL: u.URL, Remark: u.Remark, SortOrder: i, }).Error; err != nil { return err } } for _, gid := range w.Groups { if err := tx.Create(&MonitorWebsiteGroup{WebsiteID: w.ID, GroupID: gid}).Error; err != nil { return err } } return nil }) } // AddWebsite 添加网站 func (s *Storage) AddWebsite(website models.Website) error { return s.persistWebsiteFull(website) } // UpdateWebsite 更新网站 func (s *Storage) UpdateWebsite(website models.Website) error { return s.persistWebsiteFull(website) } // DeleteWebsite 删除网站 func (s *Storage) DeleteWebsite(id string) error { return s.db.Transaction(func(tx *gorm.DB) error { _ = tx.Where("website_id = ?", id).Delete(&MonitorProbeLatest{}) _ = tx.Where("website_id = ?", id).Delete(&MonitorProbeHour{}) _ = tx.Where("website_id = ?", id).Delete(&MonitorProbeDay{}) _ = tx.Where("website_id = ?", id).Delete(&MonitorWebsiteURL{}) _ = tx.Where("website_id = ?", id).Delete(&MonitorWebsiteGroup{}) return tx.Where("id = ?", id).Delete(&MonitorWebsite{}).Error }) } // GetGroups 获取所有分组 func (s *Storage) GetGroups() []models.Group { var rows []MonitorGroup s.db.Order("sort_order ASC, id ASC").Find(&rows) out := make([]models.Group, len(rows)) for i, r := range rows { out[i] = models.Group{ID: r.ID, Name: r.Name} } return out } // AddGroup 添加分组 func (s *Storage) AddGroup(group models.Group) error { var n int64 s.db.Model(&MonitorGroup{}).Count(&n) return s.db.Create(&MonitorGroup{ ID: group.ID, Name: group.Name, SortOrder: int(n) + 1, }).Error } // GetGroupByID 按 ID 获取分组(无则 nil) func (s *Storage) GetGroupByID(id string) *models.Group { var r MonitorGroup if err := s.db.First(&r, "id = ?", id).Error; err != nil { return nil } g := models.Group{ID: r.ID, Name: r.Name} return &g } // UpdateGroup 更新分组显示名称(ID 不变) func (s *Storage) UpdateGroup(id, name string) error { name = strings.TrimSpace(name) if name == "" { return fmt.Errorf("分类名称不能为空") } res := s.db.Model(&MonitorGroup{}).Where("id = ?", id).Update("name", name) if res.Error != nil { return res.Error } if res.RowsAffected == 0 { return fmt.Errorf("分类不存在") } return nil } // DeleteGroup 删除分组;从所有网站中移除该分类;若网站无剩余分类则归入删除后列表中的第一个分类。至少保留一个分类。 func (s *Storage) DeleteGroup(id string) error { var total int64 s.db.Model(&MonitorGroup{}).Count(&total) if total <= 1 { return fmt.Errorf("至少保留一个分类") } var remain []MonitorGroup s.db.Where("id != ?", id).Order("sort_order ASC, id ASC").Find(&remain) if len(remain) == 0 { return fmt.Errorf("至少保留一个分类") } fallbackID := remain[0].ID var sites []MonitorWebsite if err := s.db.Preload("GroupLinks").Find(&sites).Error; err != nil { return err } return s.db.Transaction(func(tx *gorm.DB) error { for i := range sites { next := make([]string, 0) for _, l := range sites[i].GroupLinks { if l.GroupID != id { next = append(next, l.GroupID) } } if len(next) == 0 { next = []string{fallbackID} } if err := tx.Where("website_id = ?", sites[i].ID).Delete(&MonitorWebsiteGroup{}).Error; err != nil { return err } for _, gid := range next { if err := tx.Create(&MonitorWebsiteGroup{WebsiteID: sites[i].ID, GroupID: gid}).Error; err != nil { return err } } } if err := tx.Delete(&MonitorGroup{}, "id = ?", id).Error; err != nil { return err } return nil }) } // SaveAll 周期结束时清理过期汇总分区(日表按 history_days,小时表保留约 10 天) func (s *Storage) SaveAll() error { s.purgeProbeRollups() return nil }