Files
mengyaping/mengyaping-backend/storage/storage.go
2026-05-16 19:03:32 +08:00

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