diff --git a/.gitignore b/.gitignore index b122a4f..12f1a19 100644 --- a/.gitignore +++ b/.gitignore @@ -13,6 +13,9 @@ data/ # logs/ # scripts/ configs/config.ini +web/dist/* +!web/dist/.gitkeep + # IDE .idea/ diff --git a/internal/controllers/settings_controller.go b/internal/controllers/settings_controller.go index 603a36a..70f25b4 100644 --- a/internal/controllers/settings_controller.go +++ b/internal/controllers/settings_controller.go @@ -1,6 +1,7 @@ package controllers import ( + "path/filepath" "strconv" "baihu/internal/constant" @@ -20,6 +21,7 @@ type SettingsController struct { userService *services.UserService settingsService *services.SettingsService loginLogService *services.LoginLogService + backupService *services.BackupService } func NewSettingsController(userService *services.UserService, loginLogService *services.LoginLogService) *SettingsController { @@ -27,6 +29,7 @@ func NewSettingsController(userService *services.UserService, loginLogService *s userService: userService, settingsService: services.NewSettingsService(), loginLogService: loginLogService, + backupService: services.NewBackupService(), } } @@ -174,7 +177,6 @@ func formatDuration(d time.Duration) string { return fmt.Sprintf("%d秒", seconds) } - // GetLoginLogs 获取登录日志 func (sc *SettingsController) GetLoginLogs(c *gin.Context) { page, _ := strconv.Atoi(c.DefaultQuery("page", "1")) @@ -201,3 +203,80 @@ func (sc *SettingsController) GetLoginLogs(c *gin.Context) { "page_size": pageSize, }) } + +// CreateBackup 创建备份 +func (sc *SettingsController) CreateBackup(c *gin.Context) { + _, err := sc.backupService.CreateBackup() + if err != nil { + utils.ServerError(c, "创建备份失败: "+err.Error()) + return + } + utils.SuccessMsg(c, "备份创建成功") +} + +// GetBackupStatus 获取备份状态 +func (sc *SettingsController) GetBackupStatus(c *gin.Context) { + filePath := sc.backupService.GetBackupFile() + var backupTime string + if filePath != "" { + if info, err := os.Stat(filePath); err == nil { + backupTime = info.ModTime().Format("2006-01-02 15:04:05") + } + } + utils.Success(c, gin.H{ + "has_backup": filePath != "", + "backup_time": backupTime, + }) +} + +// DownloadBackup 下载备份文件 +func (sc *SettingsController) DownloadBackup(c *gin.Context) { + filePath := sc.backupService.GetBackupFile() + if filePath == "" { + utils.NotFound(c, "没有可下载的备份") + return + } + + // 检查文件是否存在 + if _, err := os.Stat(filePath); os.IsNotExist(err) { + sc.backupService.ClearBackup() + utils.NotFound(c, "备份文件不存在") + return + } + + // 设置响应头 + c.Header("Content-Disposition", "attachment; filename="+filepath.Base(filePath)) + c.Header("Content-Type", "application/zip") + c.File(filePath) + + // 下载后清除备份记录和文件 + go func() { + time.Sleep(time.Minute * 5) // 等待下载完成 + sc.backupService.ClearBackup() + }() +} + +// RestoreBackup 恢复备份 +func (sc *SettingsController) RestoreBackup(c *gin.Context) { + file, err := c.FormFile("file") + if err != nil { + utils.BadRequest(c, "请上传备份文件") + return + } + + // 保存上传的文件 + tempPath := filepath.Join(os.TempDir(), file.Filename) + if err := c.SaveUploadedFile(file, tempPath); err != nil { + utils.ServerError(c, "保存文件失败") + return + } + defer os.Remove(tempPath) + + // 恢复备份 + if err := sc.backupService.Restore(tempPath); err != nil { + utils.ServerError(c, "恢复失败: "+err.Error()) + return + } + + utils.SuccessMsg(c, "恢复成功") +} diff --git a/internal/router/router.go b/internal/router/router.go index fae1e3d..0b35339 100644 --- a/internal/router/router.go +++ b/internal/router/router.go @@ -177,6 +177,10 @@ func Setup(c *Controllers) *gin.Engine { settings.PUT("/site", c.Settings.UpdateSiteSettings) settings.GET("/about", c.Settings.GetAbout) settings.GET("/loginlogs", c.Settings.GetLoginLogs) + settings.POST("/backup", c.Settings.CreateBackup) + settings.GET("/backup/status", c.Settings.GetBackupStatus) + settings.GET("/backup/download", c.Settings.DownloadBackup) + settings.POST("/restore", c.Settings.RestoreBackup) } // Runtime routes (依赖管理) diff --git a/internal/services/backup_service.go b/internal/services/backup_service.go new file mode 100644 index 0000000..bb164c8 --- /dev/null +++ b/internal/services/backup_service.go @@ -0,0 +1,304 @@ +package services + +import ( + "archive/zip" + "encoding/json" + "fmt" + "io" + "os" + "path/filepath" + "time" + + "baihu/internal/constant" + "baihu/internal/database" + "baihu/internal/models" +) + +type BackupService struct { + settingsService *SettingsService +} + +func NewBackupService() *BackupService { + return &BackupService{ + settingsService: NewSettingsService(), + } +} + +const ( + BackupSection = "backup" + BackupFileKey = "backup_file" + BackupDir = "./data/backups" +) + +// tableConfig 表备份配置 +type tableConfig struct { + filename string + export func() (any, error) + restore func([]byte) error +} + +func (s *BackupService) getTableConfigs() []tableConfig { + return []tableConfig{ + {"tasks.json", s.exportTable(&[]models.Task{}, true), s.restoreTable(&[]models.Task{}, true)}, + {"task_logs.json", s.exportTable(&[]models.TaskLog{}, false), s.restoreTable(&[]models.TaskLog{}, false)}, + {"envs.json", s.exportTable(&[]models.EnvironmentVariable{}, true), s.restoreTable(&[]models.EnvironmentVariable{}, true)}, + {"scripts.json", s.exportTable(&[]models.Script{}, true), s.restoreTable(&[]models.Script{}, true)}, + {"settings.json", s.exportSettings, s.restoreSettings}, + {"send_stats.json", s.exportTable(&[]models.SendStats{}, false), s.restoreTable(&[]models.SendStats{}, false)}, + {"login_logs.json", s.exportTable(&[]models.LoginLog{}, false), s.restoreTable(&[]models.LoginLog{}, false)}, + } +} + +func (s *BackupService) exportTable(dest any, unscoped bool) func() (any, error) { + return func() (any, error) { + db := database.DB + if unscoped { + db = db.Unscoped() + } + db.Find(dest) + return dest, nil + } +} + +func (s *BackupService) restoreTable(dest any, unscoped bool) func([]byte) error { + return func(data []byte) error { + if err := json.Unmarshal(data, dest); err != nil { + return err + } + return nil + } +} + +func (s *BackupService) exportSettings() (any, error) { + var data []models.Setting + database.DB.Where("section != ?", BackupSection).Find(&data) + return data, nil +} + +func (s *BackupService) restoreSettings(data []byte) error { + var settings []models.Setting + return json.Unmarshal(data, &settings) +} + +// CreateBackup 创建备份 +func (s *BackupService) CreateBackup() (string, error) { + if err := os.MkdirAll(BackupDir, 0755); err != nil { + return "", err + } + + timestamp := time.Now().Format("20060102_150405") + zipPath := filepath.Join(BackupDir, fmt.Sprintf("backup_%s.zip", timestamp)) + + zipFile, err := os.Create(zipPath) + if err != nil { + return "", err + } + defer zipFile.Close() + + zipWriter := zip.NewWriter(zipFile) + defer zipWriter.Close() + + // 导出各表 + for _, cfg := range s.getTableConfigs() { + data, err := cfg.export() + if err != nil { + return "", err + } + jsonData, err := json.MarshalIndent(data, "", " ") + if err != nil { + return "", err + } + w, err := zipWriter.Create(cfg.filename) + if err != nil { + return "", err + } + if _, err := w.Write(jsonData); err != nil { + return "", err + } + } + + // 打包 scripts 文件夹 + scriptsDir := constant.ScriptsWorkDir + if _, err := os.Stat(scriptsDir); err == nil { + if err := s.addDirToZip(zipWriter, scriptsDir, "scripts"); err != nil { + return "", err + } + } + + s.settingsService.Set(BackupSection, BackupFileKey, zipPath) + return zipPath, nil +} + +// Restore 恢复备份 +func (s *BackupService) Restore(zipPath string) error { + r, err := zip.OpenReader(zipPath) + if err != nil { + return err + } + defer r.Close() + + // 构建文件名到配置的映射 + configs := s.getTableConfigs() + fileMap := make(map[string]*zip.File) + for _, f := range r.File { + fileMap[f.Name] = f + } + + // 读取所有表数据 + tableData := make(map[string][]byte) + for _, cfg := range configs { + if f, ok := fileMap[cfg.filename]; ok { + data, err := s.readZipFile(f) + if err != nil { + return err + } + tableData[cfg.filename] = data + } + } + + // 清空现有数据(物理删除) + database.DB.Unscoped().Where("1=1").Delete(&models.Task{}) + database.DB.Unscoped().Where("1=1").Delete(&models.TaskLog{}) + database.DB.Unscoped().Where("1=1").Delete(&models.EnvironmentVariable{}) + database.DB.Unscoped().Where("1=1").Delete(&models.Script{}) + database.DB.Unscoped().Where("section != ?", BackupSection).Delete(&models.Setting{}) + database.DB.Unscoped().Where("1=1").Delete(&models.SendStats{}) + database.DB.Unscoped().Where("1=1").Delete(&models.LoginLog{}) + + // 恢复数据 + s.restoreFromData(tableData, "tasks.json", &[]models.Task{}) + s.restoreFromData(tableData, "task_logs.json", &[]models.TaskLog{}) + s.restoreFromData(tableData, "envs.json", &[]models.EnvironmentVariable{}) + s.restoreFromData(tableData, "scripts.json", &[]models.Script{}) + s.restoreFromData(tableData, "settings.json", &[]models.Setting{}) + s.restoreFromData(tableData, "send_stats.json", &[]models.SendStats{}) + s.restoreFromData(tableData, "login_logs.json", &[]models.LoginLog{}) + + // 恢复 scripts 文件夹 + s.restoreScriptsDir(r) + + return nil +} + +func (s *BackupService) restoreFromData(tableData map[string][]byte, filename string, dest any) { + if data, ok := tableData[filename]; ok { + if err := json.Unmarshal(data, dest); err == nil { + s.insertRecords(dest) + } + } +} + +func (s *BackupService) insertRecords(records any) { + switch v := records.(type) { + case *[]models.Task: + for _, r := range *v { + database.DB.Create(&r) + } + case *[]models.TaskLog: + for _, r := range *v { + database.DB.Create(&r) + } + case *[]models.EnvironmentVariable: + for _, r := range *v { + database.DB.Create(&r) + } + case *[]models.Script: + for _, r := range *v { + database.DB.Create(&r) + } + case *[]models.Setting: + for _, r := range *v { + database.DB.Create(&r) + } + case *[]models.SendStats: + for _, r := range *v { + database.DB.Create(&r) + } + case *[]models.LoginLog: + for _, r := range *v { + database.DB.Create(&r) + } + } +} + +func (s *BackupService) restoreScriptsDir(r *zip.ReadCloser) { + scriptsDir := constant.ScriptsWorkDir + for _, f := range r.File { + if len(f.Name) > 8 && f.Name[:8] == "scripts/" { + relPath := f.Name[8:] + if relPath == "" { + continue + } + fpath := filepath.Join(scriptsDir, relPath) + if f.FileInfo().IsDir() { + os.MkdirAll(fpath, 0755) + continue + } + os.MkdirAll(filepath.Dir(fpath), 0755) + if outFile, err := os.Create(fpath); err == nil { + if rc, err := f.Open(); err == nil { + io.Copy(outFile, rc) + rc.Close() + } + outFile.Close() + } + } + } +} + +func (s *BackupService) readZipFile(f *zip.File) ([]byte, error) { + rc, err := f.Open() + if err != nil { + return nil, err + } + defer rc.Close() + return io.ReadAll(rc) +} + +func (s *BackupService) addDirToZip(zipWriter *zip.Writer, srcDir, prefix string) error { + return filepath.Walk(srcDir, func(path string, info os.FileInfo, err error) error { + if err != nil { + return err + } + relPath, err := filepath.Rel(srcDir, path) + if err != nil { + return err + } + zipPath := filepath.ToSlash(filepath.Join(prefix, relPath)) + if info.IsDir() { + if relPath != "." { + _, err := zipWriter.Create(zipPath + "/") + return err + } + return nil + } + w, err := zipWriter.Create(zipPath) + if err != nil { + return err + } + file, err := os.Open(path) + if err != nil { + return err + } + defer file.Close() + _, err = io.Copy(w, file) + return err + }) +} + +func (s *BackupService) GetBackupFile() string { + var setting models.Setting + if err := database.DB.Where("section = ? AND `key` = ?", BackupSection, BackupFileKey).First(&setting).Error; err != nil { + return "" + } + return setting.Value +} + +func (s *BackupService) ClearBackup() error { + filePath := s.GetBackupFile() + if filePath != "" { + os.Remove(filePath) + database.DB.Where("section = ? AND `key` = ?", BackupSection, BackupFileKey).Delete(&models.Setting{}) + } + return nil +} diff --git a/web/src/api/index.ts b/web/src/api/index.ts index f34100b..74aeebb 100644 --- a/web/src/api/index.ts +++ b/web/src/api/index.ts @@ -121,6 +121,24 @@ export const api = { if (params?.page_size) query.set('page_size', String(params.page_size)) if (params?.username) query.set('username', params.username) return request(`/settings/loginlogs?${query}`) + }, + createBackup: () => request('/settings/backup', { method: 'POST' }), + getBackupStatus: () => request<{ has_backup: boolean; backup_time: string }>('/settings/backup/status'), + downloadBackup: () => `${BASE_URL}/settings/backup/download`, + restoreBackup: async (file: File) => { + const formData = new FormData() + formData.append('file', file) + const res = await fetch(`${BASE_URL}/settings/restore`, { + method: 'POST', + credentials: 'include', + body: formData + }) + const json: ApiResponse = await res.json() + if (json.code === 401) { + window.location.href = '/login' + throw new Error('请先登录') + } + if (json.code !== 200) throw new Error(json.msg || '恢复失败') } }, files: { diff --git a/web/src/views/settings/BackupSettings.vue b/web/src/views/settings/BackupSettings.vue new file mode 100644 index 0000000..3834958 --- /dev/null +++ b/web/src/views/settings/BackupSettings.vue @@ -0,0 +1,141 @@ + + + diff --git a/web/src/views/settings/Settings.vue b/web/src/views/settings/Settings.vue index af70ade..b2d8af6 100644 --- a/web/src/views/settings/Settings.vue +++ b/web/src/views/settings/Settings.vue @@ -4,6 +4,7 @@ import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/com import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs' import PasswordSettings from './PasswordSettings.vue' import SiteSettings from './SiteSettings.vue' +import BackupSettings from './BackupSettings.vue' import AboutSettings from './AboutSettings.vue' const activeTab = ref('password') @@ -20,6 +21,7 @@ const activeTab = ref('password') 密码修改 站点设置 + 备份恢复 关于 @@ -47,6 +49,18 @@ const activeTab = ref('password') + + + + 备份恢复 + 备份和恢复系统数据 + + + + + + + diff --git a/web/src/views/settings/SiteSettings.vue b/web/src/views/settings/SiteSettings.vue index acd864c..cdd69ec 100644 --- a/web/src/views/settings/SiteSettings.vue +++ b/web/src/views/settings/SiteSettings.vue @@ -37,7 +37,11 @@ async function loadSettings() { async function saveSettings() { loading.value = true try { - await api.settings.updateSite(form.value) + await api.settings.updateSite({ + ...form.value, + page_size: String(form.value.page_size), + cookie_days: String(form.value.cookie_days) + }) await refreshSettings() toast.success('保存成功') } catch {