liuq před 2 měsíci
rodič
revize
f43797ca78

+ 5 - 4
backend/models/backup.go

@@ -21,10 +21,11 @@ type BackupLog struct {
 
 // BackupConfig 备份配置 (非数据库表,仅用于API交互,实际存储在 SysConfig)
 type BackupConfig struct {
-	Enabled   bool   `json:"enabled"`
-	Time      string `json:"time"`     // "03:00"
-	KeepDays  int    `json:"keepDays"` // Local retention or logic retention
-	Endpoint  string `json:"endpoint"`
+	Enabled         bool   `json:"enabled"`         // 数据库自动备份开关
+	ResourceEnabled bool   `json:"resourceEnabled"` // 资源与物联中心自动备份开关
+	Time            string `json:"time"`            // "03:00"
+	KeepDays        int    `json:"keepDays"`        // Local retention or logic retention
+	Endpoint        string `json:"endpoint"`
 	AccessKey string `json:"accessKey"`
 	SecretKey string `json:"secretKey"`
 	Bucket    string `json:"bucket"`

+ 130 - 3
backend/services/backup_service.go

@@ -1,6 +1,7 @@
 package services
 
 import (
+	"archive/zip"
 	"context"
 	"encoding/json"
 	"fmt"
@@ -43,7 +44,7 @@ func (s *BackupService) LoadAndSchedule() {
 		return
 	}
 
-	if config.Enabled && config.Time != "" {
+	if (config.Enabled || config.ResourceEnabled) && config.Time != "" {
 		s.ScheduleBackup(config.Time)
 	} else {
 		s.StopSchedule()
@@ -70,7 +71,13 @@ func (s *BackupService) ScheduleBackup(timeStr string) {
 
 	id, err := s.Cron.AddFunc(spec, func() {
 		log.Println("Starting scheduled backup...")
-		s.PerformBackup()
+		config, _ := s.GetConfig()
+		if config.Enabled {
+			s.PerformBackup()
+		}
+		if config.ResourceEnabled {
+			s.PerformResourceBackup()
+		}
 	})
 	if err != nil {
 		log.Printf("Failed to schedule backup: %v", err)
@@ -117,7 +124,7 @@ func (s *BackupService) SaveConfig(config *models.BackupConfig) error {
 	}
 
 	// Reschedule
-	if config.Enabled {
+	if config.Enabled || config.ResourceEnabled {
 		s.ScheduleBackup(config.Time)
 	} else {
 		s.StopSchedule()
@@ -373,3 +380,123 @@ func (s *BackupService) DownloadFile(logID string) (*minio.Object, string, error
 
 	return object, backupLog.FileName, nil
 }
+
+func (s *BackupService) PerformResourceBackup() {
+	s.Lock.Lock()
+	defer s.Lock.Unlock()
+
+	startTime := time.Now()
+	// 使用 _resource_ 前缀区分数据库备份
+	fileName := fmt.Sprintf("ems_resource_%s.zip", startTime.Format("20060102_150405"))
+	
+	backupLog := models.BackupLog{
+		ID:           uuid.New(),
+		StartTime:    startTime,
+		Status:       "RUNNING",
+		UploadStatus: "PENDING",
+		FileName:     fileName,
+	}
+	if err := models.DB.Create(&backupLog).Error; err != nil {
+		log.Printf("[ERROR] Failed to create resource backup log: %v", err)
+	}
+
+	// 1. 查询数据 (资源与物联中心的四个部分)
+	var sources []models.IntegrationSource
+	var devices []models.Device
+	var templates []models.EquipmentCleaningFormulaTemplate
+	var locations []models.SysLocation
+
+	// 查询数据
+	models.DB.Find(&sources)
+	models.DB.Find(&devices)
+	models.DB.Find(&templates)
+	models.DB.Find(&locations)
+
+	// 2. 创建 Zip 文件
+	backupDir := "backups"
+	if _, err := os.Stat(backupDir); os.IsNotExist(err) {
+		os.MkdirAll(backupDir, 0755)
+	}
+	filePath := filepath.Join(backupDir, fileName)
+
+	zipFile, err := os.Create(filePath)
+	if err != nil {
+		s.logBackupError(&backupLog, fmt.Sprintf("Failed to create zip file: %v", err))
+		return
+	}
+	// defer will be called when function returns
+	defer zipFile.Close()
+
+	archive := zip.NewWriter(zipFile)
+	defer archive.Close()
+
+	// 辅助函数: 写入 JSON 到 Zip
+	writeJSON := func(name string, data interface{}) error {
+		w, err := archive.Create(name)
+		if err != nil {
+			return err
+		}
+		encoder := json.NewEncoder(w)
+		encoder.SetIndent("", "  ")
+		return encoder.Encode(data)
+	}
+
+	// 写入四个部分的 JSON 文件
+	if err := writeJSON("integration_sources.json", sources); err != nil {
+		s.logBackupError(&backupLog, fmt.Sprintf("Failed to write sources: %v", err))
+		return
+	}
+	if err := writeJSON("devices.json", devices); err != nil {
+		s.logBackupError(&backupLog, fmt.Sprintf("Failed to write devices: %v", err))
+		return
+	}
+	if err := writeJSON("cleaning_templates.json", templates); err != nil {
+		s.logBackupError(&backupLog, fmt.Sprintf("Failed to write templates: %v", err))
+		return
+	}
+	if err := writeJSON("sys_locations.json", locations); err != nil {
+		s.logBackupError(&backupLog, fmt.Sprintf("Failed to write locations: %v", err))
+		return
+	}
+
+	// 关闭 Zip Writer 以确保所有数据写入文件
+	if err := archive.Close(); err != nil {
+		s.logBackupError(&backupLog, fmt.Sprintf("Failed to close archive: %v", err))
+		return
+	}
+	// Note: zipFile.Close() is deferred
+
+	// 3. 更新日志并上传
+	info, err := os.Stat(filePath)
+	if err == nil {
+		backupLog.Size = info.Size()
+	}
+	backupLog.FilePath = fileName // 保存文件名作为相对路径
+	backupLog.Status = "SUCCESS"
+	backupLog.Message = "Resource backup created successfully."
+	backupLog.EndTime = time.Now()
+	models.DB.Save(&backupLog)
+
+	// 4. 上传到 MinIO (复用现有的上传逻辑)
+	config, _ := s.GetConfig()
+	if config.Endpoint != "" && config.Bucket != "" {
+		err := s.uploadToMinIO(filePath, fileName, config)
+		if err != nil {
+			log.Printf("MinIO upload failed: %v", err)
+			backupLog.Message += fmt.Sprintf(" Upload failed: %v", err)
+			backupLog.UploadStatus = "FAILED"
+		} else {
+			backupLog.UploadStatus = "UPLOADED"
+			backupLog.Message += " Uploaded to MinIO."
+		}
+		models.DB.Save(&backupLog)
+	}
+}
+
+// 辅助方法: 记录错误
+func (s *BackupService) logBackupError(log *models.BackupLog, msg string) {
+	log.Status = "FAILED"
+	log.Message = msg
+	log.EndTime = time.Now()
+	models.DB.Save(log)
+}

+ 0 - 1
docker-compose.wsl.yml

@@ -10,7 +10,6 @@ services:
     ports: ["80:80", "443:443"]
     volumes:
       - ./configs/nginx/conf.d:/etc/nginx/conf.d
-      - ./frontend/dist:/usr/share/nginx/html # 开发环境挂载 dist,方便查看构建结果
     depends_on:
       app-server:
         condition: service_started

+ 7 - 1
frontend/src/views/ops/DataBackup.vue

@@ -9,8 +9,13 @@
         </div>
       </template>
       <a-form :model="config" :label-col-props="{span: 6}" :wrapper-col-props="{span: 18}" style="max-width: 800px;">
-        <a-form-item label="自动备份开关">
+        <a-form-item label="数据库自动备份">
           <a-switch v-model="config.enabled" active-text="开启" inactive-text="关闭" />
+          <span style="margin-left: 10px; color: #909399;">(备份完整的 PostgreSQL 数据库)</span>
+        </a-form-item>
+        <a-form-item label="资源配置自动备份">
+          <a-switch v-model="config.resourceEnabled" active-text="开启" inactive-text="关闭" />
+          <span style="margin-left: 10px; color: #909399;">(导出资源中心配置为 Zip: 数据源、设备、模板、拓扑)</span>
         </a-form-item>
         <a-form-item label="每日备份时间">
           <a-time-picker v-model="config.time" format="HH:mm" value-format="HH:mm" placeholder="选择时间" style="width: 100%;" />
@@ -89,6 +94,7 @@ const columns = [
 
 const config = reactive({
   enabled: false,
+  resourceEnabled: false,
   time: '03:00',
   keepDays: 7,
   endpoint: '',