366 lines
8.8 KiB
Go
366 lines
8.8 KiB
Go
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
|
||
}
|