diff --git a/README.md b/README.md index e3c51cc..b817756 100644 --- a/README.md +++ b/README.md @@ -64,7 +64,7 @@ BackupX 是一个面向 **Linux / macOS 服务器**的自托管备份管理平 ## Features ### 📦 多种备份类型 -- **文件/目录** — 支持自定义排除规则(如 `node_modules`、`*.log`) +- **文件/目录** — 支持多源路径备份,自定义排除规则(如 `node_modules`、`*.log`) - **MySQL** — 通过 `mysqldump` 原生工具 - **SQLite** — 安全文件拷贝 - **PostgreSQL** — 通过 `pg_dump` 原生工具 @@ -103,11 +103,13 @@ BackupX 是一个面向 **Linux / macOS 服务器**的自托管备份管理平 - 可选备份文件加密 - 登录限流防暴力破解 - 节点 Token 认证(一次性显示,安全传输) +- CLI 密码重置(Docker 用户通过 `docker exec` 执行,无需邮件找回) ### 📊 监控与通知 - 仪表盘统计(成功率、存储用量、备份趋势图表) - 邮件 / Webhook / Telegram 通知 - 实时备份执行日志 (SSE) +- **审计日志** — 全操作链可溯源,记录登录、任务创建/修改/删除、备份执行/恢复等关键事件 ### 🌐 其他 - 中英文国际化 (i18n) @@ -310,6 +312,7 @@ BackupX/ │ │ ├── storage-targets/ # 存储目标 │ │ ├── nodes/ # 节点管理 │ │ ├── notifications/ # 通知配置 +│ │ ├── audit/ # 审计日志 │ │ ├── settings/ # 系统设置 │ │ └── login/ # 登录页 │ ├── services/ # API 请求封装 @@ -407,6 +410,12 @@ docker build -t backupx . docker run -d --name backupx -p 8340:8340 -v backupx-data:/app/data backupx ``` +**密码重置**(忘记管理员密码时): + +```bash +docker exec -it backupx /app/bin/backupx reset-password --username admin --password newpass123 +``` + 通过环境变量覆盖配置: ```bash @@ -435,6 +444,18 @@ scp server/config.example.yaml your-server:/etc/backupx/config.yaml ssh your-server '/opt/backupx/bin/backupx -config /etc/backupx/config.yaml' ``` +### 密码重置 + +忘记管理员密码时,可通过 CLI 直接重置(需要服务器 shell 权限): + +```bash +# 裸机部署 +./backupx reset-password --username admin --password newpass123 + +# Docker 部署 +docker exec -it backupx /app/bin/backupx reset-password --username admin --password newpass123 +``` + ### Nginx 配置示例 ```nginx @@ -491,6 +512,7 @@ server { | | `POST /api/notifications/:id/test` | 测试已保存通知 | | **仪表盘** | `GET /api/dashboard/stats` | 概览统计 | | | `GET /api/dashboard/timeline` | 备份趋势时间线 | +| **审计日志** | `GET /api/audit-logs` | 审计日志列表 (支持分类筛选/分页) | | **系统** | `GET /api/system/info` | 系统信息 (版本/磁盘) | | | `GET/PUT /api/settings` | 系统设置读写 | diff --git a/README_EN.md b/README_EN.md index 39e663d..e6b3c20 100644 --- a/README_EN.md +++ b/README_EN.md @@ -64,7 +64,7 @@ Supports **multi-node cluster management** for unified control of backup tasks a ## Features ### 📦 Multiple Backup Types -- **Files / Directories** — Custom exclude rules (e.g. `node_modules`, `*.log`) +- **Files / Directories** — Multi-source path backup, custom exclude rules (e.g. `node_modules`, `*.log`) - **MySQL** — Via native `mysqldump` tool - **SQLite** — Safe file copy - **PostgreSQL** — Via native `pg_dump` tool @@ -103,11 +103,13 @@ Supports **multi-node cluster management** for unified control of backup tasks a - Optional backup file encryption - Login rate limiting (brute force protection) - Node Token authentication (one-time display, secure transport) +- CLI password reset (Docker users can run `docker exec` — no email recovery needed) ### 📊 Monitoring & Notifications - Dashboard stats (success rate, storage usage, backup trend charts) - Email / Webhook / Telegram notifications - Real-time backup execution logs (SSE) +- **Audit Logs** — Full operation traceability, records login, task CRUD, backup execution/restore and other key events ### 🌐 Other - Chinese & English i18n @@ -311,6 +313,7 @@ BackupX/ │ │ ├── storage-targets/ # Storage targets │ │ ├── nodes/ # Node management │ │ ├── notifications/ # Notification settings +│ │ ├── audit/ # Audit logs │ │ ├── settings/ # System settings │ │ └── login/ # Login page │ ├── services/ # API request wrappers @@ -408,6 +411,12 @@ docker build -t backupx . docker run -d --name backupx -p 8340:8340 -v backupx-data:/app/data backupx ``` +**Password Reset** (when you forget the admin password): + +```bash +docker exec -it backupx /app/bin/backupx reset-password --username admin --password newpass123 +``` + Override configuration via environment variables: ```bash @@ -436,6 +445,18 @@ scp server/config.example.yaml your-server:/etc/backupx/config.yaml ssh your-server '/opt/backupx/bin/backupx -config /etc/backupx/config.yaml' ``` +### Password Reset + +When you forget the admin password, reset it via CLI (requires server shell access): + +```bash +# Bare metal +./backupx reset-password --username admin --password newpass123 + +# Docker +docker exec -it backupx /app/bin/backupx reset-password --username admin --password newpass123 +``` + ### Nginx Config Example ```nginx @@ -492,6 +513,7 @@ All APIs are prefixed with `/api` and use JWT Bearer Token authentication (unles | | `POST /api/notifications/:id/test` | Test saved notification | | **Dashboard** | `GET /api/dashboard/stats` | Overview statistics | | | `GET /api/dashboard/timeline` | Backup trend timeline | +| **Audit Logs** | `GET /api/audit-logs` | Audit log list (with category filter/pagination) | | **System** | `GET /api/system/info` | System info (version/disk) | | | `GET/PUT /api/settings` | System settings | diff --git a/server/cmd/backupx/main.go b/server/cmd/backupx/main.go index 1fac0a9..cc10407 100644 --- a/server/cmd/backupx/main.go +++ b/server/cmd/backupx/main.go @@ -10,11 +10,21 @@ import ( "backupx/server/internal/app" "backupx/server/internal/config" + "backupx/server/internal/security" + "github.com/glebarez/sqlite" + "gorm.io/gorm" + gormlogger "gorm.io/gorm/logger" ) var version = "dev" func main() { + // 子命令分发:reset-password + if len(os.Args) > 1 && os.Args[1] == "reset-password" { + runResetPassword(os.Args[2:]) + return + } + var configPath string var showVersion bool @@ -48,3 +58,58 @@ func main() { os.Exit(1) } } + +// runResetPassword 通过 CLI 直接操作 SQLite 重置用户密码,无需完整 app 初始化。 +// 用法:backupx reset-password --username admin --password newpass123 [--config path] +func runResetPassword(args []string) { + fs := flag.NewFlagSet("reset-password", flag.ExitOnError) + username := fs.String("username", "admin", "要重置密码的用户名") + password := fs.String("password", "", "新密码(至少 8 个字符)") + configPath := fs.String("config", "", "配置文件路径") + if err := fs.Parse(args); err != nil { + os.Exit(1) + } + + if *password == "" { + fmt.Fprintln(os.Stderr, "错误:--password 参数为必填项") + fs.Usage() + os.Exit(1) + } + if len(*password) < 8 { + fmt.Fprintln(os.Stderr, "错误:密码长度至少 8 个字符") + os.Exit(1) + } + + cfg, err := config.Load(*configPath) + if err != nil { + fmt.Fprintf(os.Stderr, "加载配置失败:%v\n", err) + os.Exit(1) + } + + db, err := gorm.Open(sqlite.Open(cfg.Database.Path), &gorm.Config{Logger: gormlogger.Default.LogMode(gormlogger.Silent)}) + if err != nil { + fmt.Fprintf(os.Stderr, "打开数据库失败:%v\n", err) + os.Exit(1) + } + + var count int64 + db.Table("users").Where("username = ?", *username).Count(&count) + if count == 0 { + fmt.Fprintf(os.Stderr, "错误:用户 %q 不存在\n", *username) + os.Exit(1) + } + + hash, err := security.HashPassword(*password) + if err != nil { + fmt.Fprintf(os.Stderr, "密码哈希失败:%v\n", err) + os.Exit(1) + } + + result := db.Table("users").Where("username = ?", *username).Update("password_hash", hash) + if result.Error != nil { + fmt.Fprintf(os.Stderr, "密码更新失败:%v\n", result.Error) + os.Exit(1) + } + + fmt.Printf("用户 %q 密码已重置成功\n", *username) +} diff --git a/server/internal/app/app.go b/server/internal/app/app.go index 6fe7efc..f9db063 100644 --- a/server/internal/app/app.go +++ b/server/internal/app/app.go @@ -95,6 +95,14 @@ func New(ctx context.Context, cfg config.Config, version string) (*Application, dashboardService := service.NewDashboardService(backupTaskRepo, backupRecordRepo, storageTargetRepo) settingsService := service.NewSettingsService(systemConfigRepo) + // Audit + auditLogRepo := repository.NewAuditLogRepository(db) + auditService := service.NewAuditService(auditLogRepo) + authService.SetAuditService(auditService) + + // Database discovery + databaseDiscoveryService := service.NewDatabaseDiscoveryService(backup.NewOSCommandExecutor()) + // Cluster: Node management nodeRepo := repository.NewNodeRepository(db) nodeService := service.NewNodeService(nodeRepo) @@ -115,8 +123,10 @@ func New(ctx context.Context, cfg config.Config, version string) (*Application, NotificationService: notificationService, DashboardService: dashboardService, SettingsService: settingsService, - NodeService: nodeService, - JWTManager: jwtManager, + NodeService: nodeService, + DatabaseDiscoveryService: databaseDiscoveryService, + AuditService: auditService, + JWTManager: jwtManager, UserRepository: userRepo, SystemConfigRepo: systemConfigRepo, }) diff --git a/server/internal/backup/file_runner.go b/server/internal/backup/file_runner.go index e1840cf..feb06f5 100644 --- a/server/internal/backup/file_runner.go +++ b/server/internal/backup/file_runner.go @@ -22,14 +22,23 @@ func (r *FileRunner) Type() string { } func (r *FileRunner) Run(_ context.Context, task TaskSpec, writer LogWriter) (*RunResult, error) { - sourcePath := filepath.Clean(strings.TrimSpace(task.SourcePath)) - if sourcePath == "" { + // 解析源路径列表:优先 SourcePaths,回退 SourcePath + sourcePaths := task.SourcePaths + if len(sourcePaths) == 0 && strings.TrimSpace(task.SourcePath) != "" { + sourcePaths = []string{task.SourcePath} + } + if len(sourcePaths) == 0 { return nil, fmt.Errorf("source path is required") } - info, err := os.Stat(sourcePath) - if err != nil { - return nil, fmt.Errorf("stat source path: %w", err) + + // 验证所有路径存在 + for _, sp := range sourcePaths { + cleaned := filepath.Clean(strings.TrimSpace(sp)) + if _, err := os.Stat(cleaned); err != nil { + return nil, fmt.Errorf("stat source path %s: %w", cleaned, err) + } } + tempDir, artifactPath, err := createTempArtifact(task.TempDir, task.Name, "tar") if err != nil { return nil, err @@ -41,69 +50,88 @@ func (r *FileRunner) Run(_ context.Context, task TaskSpec, writer LogWriter) (*R defer artifactFile.Close() tw := tar.NewWriter(artifactFile) defer tw.Close() - baseParent := filepath.Dir(sourcePath) + excludes := normalizeExcludePatterns(task.ExcludePatterns) - writer.WriteLine(fmt.Sprintf("开始打包文件备份:%s", sourcePath)) - fileCount := 0 - dirCount := 0 - walkErr := filepath.Walk(sourcePath, func(currentPath string, currentInfo os.FileInfo, walkErr error) error { - if walkErr != nil { - writer.WriteLine(fmt.Sprintf("⚠ 无法访问 %s: %v", currentPath, walkErr)) - return nil - } - relPath, err := filepath.Rel(baseParent, currentPath) + totalFileCount := 0 + totalDirCount := 0 + + for i, sp := range sourcePaths { + sourcePath := filepath.Clean(strings.TrimSpace(sp)) + info, err := os.Stat(sourcePath) if err != nil { - return err + return nil, fmt.Errorf("stat source path: %w", err) } - archiveName := filepath.ToSlash(relPath) - if shouldExcludeEntry(archiveName, currentInfo.IsDir(), excludes) { - if currentInfo.IsDir() { - writer.WriteLine(fmt.Sprintf("跳过排除目录 %s", archiveName)) - return filepath.SkipDir + + baseParent := filepath.Dir(sourcePath) + writer.WriteLine(fmt.Sprintf("开始打包源路径 [%d/%d]: %s", i+1, len(sourcePaths), sourcePath)) + fileCount := 0 + dirCount := 0 + + walkErr := filepath.Walk(sourcePath, func(currentPath string, currentInfo os.FileInfo, walkErr error) error { + if walkErr != nil { + writer.WriteLine(fmt.Sprintf("⚠ 无法访问 %s: %v", currentPath, walkErr)) + return nil } - return nil - } - if currentPath == sourcePath && currentInfo.IsDir() { - return nil - } - - if currentInfo.IsDir() { - dirCount++ - writer.WriteLine(fmt.Sprintf("📁 进入目录 %s", archiveName)) - } - - header, err := tar.FileInfoHeader(currentInfo, "") - if err != nil { - return err - } - header.Name = archiveName - if err := tw.WriteHeader(header); err != nil { - return err - } - - if currentInfo.Mode().IsRegular() { - file, err := os.Open(currentPath) + relPath, err := filepath.Rel(baseParent, currentPath) if err != nil { return err } - defer file.Close() - if _, err := io.CopyN(tw, file, currentInfo.Size()); err != nil && err != io.EOF { + archiveName := filepath.ToSlash(relPath) + if shouldExcludeEntry(archiveName, currentInfo.IsDir(), excludes) { + if currentInfo.IsDir() { + writer.WriteLine(fmt.Sprintf("跳过排除目录 %s", archiveName)) + return filepath.SkipDir + } + return nil + } + if currentPath == sourcePath && currentInfo.IsDir() { + return nil + } + + if currentInfo.IsDir() { + dirCount++ + writer.WriteLine(fmt.Sprintf("📁 进入目录 %s", archiveName)) + } + + header, err := tar.FileInfoHeader(currentInfo, "") + if err != nil { return err } - fileCount++ - if fileCount%100 == 0 { - writer.WriteLine(fmt.Sprintf("已打包 %d 个文件...", fileCount)) + header.Name = archiveName + if err := tw.WriteHeader(header); err != nil { + return err } + + if currentInfo.Mode().IsRegular() { + file, err := os.Open(currentPath) + if err != nil { + return err + } + defer file.Close() + if _, err := io.CopyN(tw, file, currentInfo.Size()); err != nil && err != io.EOF { + return err + } + fileCount++ + if fileCount%100 == 0 { + writer.WriteLine(fmt.Sprintf("已打包 %d 个文件...", fileCount)) + } + } + return nil + }) + if walkErr != nil { + return nil, fmt.Errorf("walk source path %s: %w", sourcePath, walkErr) } - return nil - }) - if walkErr != nil { - return nil, fmt.Errorf("walk source path: %w", walkErr) + if info.IsDir() { + writer.WriteLine(fmt.Sprintf("源路径 [%d/%d] 打包完成(%d 个目录,%d 个文件)", i+1, len(sourcePaths), dirCount, fileCount)) + } else { + writer.WriteLine(fmt.Sprintf("源路径 [%d/%d] 文件打包完成", i+1, len(sourcePaths))) + } + totalFileCount += fileCount + totalDirCount += dirCount } - if info.IsDir() { - writer.WriteLine(fmt.Sprintf("目录打包完成(%d 个目录,%d 个文件)", dirCount, fileCount)) - } else { - writer.WriteLine("文件打包完成") + + if len(sourcePaths) > 1 { + writer.WriteLine(fmt.Sprintf("全部源路径打包完成(共 %d 个目录,%d 个文件)", totalDirCount, totalFileCount)) } return &RunResult{ArtifactPath: artifactPath, FileName: filepath.Base(artifactPath), TempDir: tempDir}, nil } @@ -114,7 +142,12 @@ func (r *FileRunner) Restore(_ context.Context, task TaskSpec, artifactPath stri return fmt.Errorf("open tar artifact: %w", err) } defer artifactFile.Close() - targetParent := filepath.Dir(filepath.Clean(strings.TrimSpace(task.SourcePath))) + // 恢复目标:优先取 SourcePaths 的第一个路径的父目录,回退 SourcePath + restoreSource := task.SourcePath + if len(task.SourcePaths) > 0 { + restoreSource = task.SourcePaths[0] + } + targetParent := filepath.Dir(filepath.Clean(strings.TrimSpace(restoreSource))) if err := os.MkdirAll(targetParent, 0o755); err != nil { return fmt.Errorf("create restore parent: %w", err) } diff --git a/server/internal/backup/types.go b/server/internal/backup/types.go index 27bffe0..e9aebae 100644 --- a/server/internal/backup/types.go +++ b/server/internal/backup/types.go @@ -19,6 +19,7 @@ type TaskSpec struct { Name string Type string SourcePath string + SourcePaths []string ExcludePatterns []string Database DatabaseSpec StorageTargetID uint diff --git a/server/internal/database/database.go b/server/internal/database/database.go index dcf1032..1f4c906 100644 --- a/server/internal/database/database.go +++ b/server/internal/database/database.go @@ -23,10 +23,17 @@ func Open(cfg config.DatabaseConfig, logger *zap.Logger) (*gorm.DB, error) { return nil, fmt.Errorf("open sqlite: %w", err) } - if err := db.AutoMigrate(&model.User{}, &model.SystemConfig{}, &model.StorageTarget{}, &model.OAuthSession{}, &model.BackupTask{}, &model.BackupRecord{}, &model.Notification{}, &model.Node{}); err != nil { + if err := db.AutoMigrate(&model.User{}, &model.SystemConfig{}, &model.StorageTarget{}, &model.OAuthSession{}, &model.BackupTask{}, &model.BackupRecord{}, &model.Notification{}, &model.Node{}, &model.BackupTaskStorageTarget{}, &model.AuditLog{}); err != nil { return nil, fmt.Errorf("migrate schema: %w", err) } + // 一次性数据迁移:从 backup_tasks.storage_target_id 回填到多对多中间表 + var count int64 + db.Model(&model.BackupTaskStorageTarget{}).Count(&count) + if count == 0 { + db.Exec("INSERT INTO backup_task_storage_targets (backup_task_id, storage_target_id) SELECT id, storage_target_id FROM backup_tasks WHERE storage_target_id > 0") + } + logger.Info("database initialized", zap.String("path", cfg.Path)) return db, nil } diff --git a/server/internal/http/audit_handler.go b/server/internal/http/audit_handler.go new file mode 100644 index 0000000..b2670ea --- /dev/null +++ b/server/internal/http/audit_handler.go @@ -0,0 +1,40 @@ +package http + +import ( + "strconv" + "strings" + + "backupx/server/internal/service" + "backupx/server/pkg/response" + "github.com/gin-gonic/gin" +) + +type AuditHandler struct { + auditService *service.AuditService +} + +func NewAuditHandler(auditService *service.AuditService) *AuditHandler { + return &AuditHandler{auditService: auditService} +} + +func (h *AuditHandler) List(c *gin.Context) { + category := strings.TrimSpace(c.Query("category")) + limit := 50 + offset := 0 + if v := strings.TrimSpace(c.Query("limit")); v != "" { + if parsed, err := strconv.Atoi(v); err == nil && parsed > 0 { + limit = parsed + } + } + if v := strings.TrimSpace(c.Query("offset")); v != "" { + if parsed, err := strconv.Atoi(v); err == nil && parsed >= 0 { + offset = parsed + } + } + result, err := h.auditService.List(c.Request.Context(), category, limit, offset) + if err != nil { + response.Error(c, err) + return + } + response.Success(c, result) +} diff --git a/server/internal/http/audit_helpers.go b/server/internal/http/audit_helpers.go new file mode 100644 index 0000000..19d137e --- /dev/null +++ b/server/internal/http/audit_helpers.go @@ -0,0 +1,29 @@ +package http + +import ( + "fmt" + + "backupx/server/internal/service" + "github.com/gin-gonic/gin" +) + +// recordAudit 从 gin context 中提取用户信息并记录审计日志(nil 安全) +func recordAudit(c *gin.Context, auditService *service.AuditService, category, action, targetType, targetID, targetName, detail string) { + if auditService == nil { + return + } + username := "" + if subject, exists := c.Get(contextUserSubjectKey); exists { + username = fmt.Sprintf("%v", subject) + } + auditService.Record(service.AuditEntry{ + Username: username, + Category: category, + Action: action, + TargetType: targetType, + TargetID: targetID, + TargetName: targetName, + Detail: detail, + ClientIP: c.ClientIP(), + }) +} diff --git a/server/internal/http/backup_record_handler.go b/server/internal/http/backup_record_handler.go index 354817b..0f13237 100644 --- a/server/internal/http/backup_record_handler.go +++ b/server/internal/http/backup_record_handler.go @@ -16,11 +16,12 @@ import ( ) type BackupRecordHandler struct { - service *service.BackupRecordService + service *service.BackupRecordService + auditService *service.AuditService } -func NewBackupRecordHandler(recordService *service.BackupRecordService) *BackupRecordHandler { - return &BackupRecordHandler{service: recordService} +func NewBackupRecordHandler(recordService *service.BackupRecordService, auditService *service.AuditService) *BackupRecordHandler { + return &BackupRecordHandler{service: recordService, auditService: auditService} } func (h *BackupRecordHandler) List(c *gin.Context) { @@ -129,6 +130,7 @@ func (h *BackupRecordHandler) Restore(c *gin.Context) { response.Error(c, err) return } + recordAudit(c, h.auditService, "backup_record", "restore", "backup_record", fmt.Sprintf("%d", id), "", "") response.Success(c, gin.H{"restored": true}) } @@ -141,6 +143,7 @@ func (h *BackupRecordHandler) Delete(c *gin.Context) { response.Error(c, err) return } + recordAudit(c, h.auditService, "backup_record", "delete", "backup_record", fmt.Sprintf("%d", id), "", "") response.Success(c, gin.H{"deleted": true}) } diff --git a/server/internal/http/backup_run_handler.go b/server/internal/http/backup_run_handler.go index 8fc94dc..ce46598 100644 --- a/server/internal/http/backup_run_handler.go +++ b/server/internal/http/backup_run_handler.go @@ -1,17 +1,20 @@ package http import ( + "fmt" + "backupx/server/internal/service" "backupx/server/pkg/response" "github.com/gin-gonic/gin" ) type BackupRunHandler struct { - service *service.BackupExecutionService + service *service.BackupExecutionService + auditService *service.AuditService } -func NewBackupRunHandler(executionService *service.BackupExecutionService) *BackupRunHandler { - return &BackupRunHandler{service: executionService} +func NewBackupRunHandler(executionService *service.BackupExecutionService, auditService *service.AuditService) *BackupRunHandler { + return &BackupRunHandler{service: executionService, auditService: auditService} } func (h *BackupRunHandler) Run(c *gin.Context) { @@ -24,5 +27,6 @@ func (h *BackupRunHandler) Run(c *gin.Context) { response.Error(c, err) return } + recordAudit(c, h.auditService, "backup_task", "run", "backup_task", fmt.Sprintf("%d", id), "", "手动触发备份") response.Success(c, record) } diff --git a/server/internal/http/backup_task_handler.go b/server/internal/http/backup_task_handler.go index 596e5c7..41da780 100644 --- a/server/internal/http/backup_task_handler.go +++ b/server/internal/http/backup_task_handler.go @@ -1,6 +1,8 @@ package http import ( + "fmt" + "backupx/server/internal/apperror" "backupx/server/internal/service" "backupx/server/pkg/response" @@ -8,11 +10,12 @@ import ( ) type BackupTaskHandler struct { - service *service.BackupTaskService + service *service.BackupTaskService + auditService *service.AuditService } -func NewBackupTaskHandler(taskService *service.BackupTaskService) *BackupTaskHandler { - return &BackupTaskHandler{service: taskService} +func NewBackupTaskHandler(taskService *service.BackupTaskService, auditService *service.AuditService) *BackupTaskHandler { + return &BackupTaskHandler{service: taskService, auditService: auditService} } func (h *BackupTaskHandler) List(c *gin.Context) { @@ -48,6 +51,7 @@ func (h *BackupTaskHandler) Create(c *gin.Context) { response.Error(c, err) return } + recordAudit(c, h.auditService, "backup_task", "create", "backup_task", fmt.Sprintf("%d", item.ID), item.Name, "") response.Success(c, item) } @@ -66,6 +70,7 @@ func (h *BackupTaskHandler) Update(c *gin.Context) { response.Error(c, err) return } + recordAudit(c, h.auditService, "backup_task", "update", "backup_task", fmt.Sprintf("%d", item.ID), item.Name, "") response.Success(c, item) } @@ -78,6 +83,7 @@ func (h *BackupTaskHandler) Delete(c *gin.Context) { response.Error(c, err) return } + recordAudit(c, h.auditService, "backup_task", "delete", "backup_task", fmt.Sprintf("%d", id), "", "") response.Success(c, gin.H{"deleted": true}) } @@ -105,5 +111,10 @@ func (h *BackupTaskHandler) Toggle(c *gin.Context) { response.Error(c, err) return } + action := "enable" + if !enabled { + action = "disable" + } + recordAudit(c, h.auditService, "backup_task", action, "backup_task", fmt.Sprintf("%d", id), item.Name, "") response.Success(c, item) } diff --git a/server/internal/http/database_handler.go b/server/internal/http/database_handler.go new file mode 100644 index 0000000..2b540f1 --- /dev/null +++ b/server/internal/http/database_handler.go @@ -0,0 +1,30 @@ +package http + +import ( + "backupx/server/internal/apperror" + "backupx/server/internal/service" + "backupx/server/pkg/response" + "github.com/gin-gonic/gin" +) + +type DatabaseHandler struct { + service *service.DatabaseDiscoveryService +} + +func NewDatabaseHandler(service *service.DatabaseDiscoveryService) *DatabaseHandler { + return &DatabaseHandler{service: service} +} + +func (h *DatabaseHandler) Discover(c *gin.Context) { + var input service.DatabaseDiscoverInput + if err := c.ShouldBindJSON(&input); err != nil { + response.Error(c, apperror.BadRequest("DATABASE_DISCOVER_INVALID", "数据库发现参数不合法", err)) + return + } + result, err := h.service.Discover(c.Request.Context(), input) + if err != nil { + response.Error(c, err) + return + } + response.Success(c, result) +} diff --git a/server/internal/http/router.go b/server/internal/http/router.go index dd46381..f6a32de 100644 --- a/server/internal/http/router.go +++ b/server/internal/http/router.go @@ -15,22 +15,24 @@ import ( ) type RouterDependencies struct { - Config config.Config - Version string - Logger *zap.Logger - AuthService *service.AuthService - SystemService *service.SystemService - StorageTargetService *service.StorageTargetService - BackupTaskService *service.BackupTaskService - BackupExecutionService *service.BackupExecutionService - BackupRecordService *service.BackupRecordService - NotificationService *service.NotificationService - DashboardService *service.DashboardService - SettingsService *service.SettingsService - NodeService *service.NodeService - JWTManager *security.JWTManager - UserRepository repository.UserRepository - SystemConfigRepo repository.SystemConfigRepository + Config config.Config + Version string + Logger *zap.Logger + AuthService *service.AuthService + SystemService *service.SystemService + StorageTargetService *service.StorageTargetService + BackupTaskService *service.BackupTaskService + BackupExecutionService *service.BackupExecutionService + BackupRecordService *service.BackupRecordService + NotificationService *service.NotificationService + DashboardService *service.DashboardService + SettingsService *service.SettingsService + NodeService *service.NodeService + DatabaseDiscoveryService *service.DatabaseDiscoveryService + AuditService *service.AuditService + JWTManager *security.JWTManager + UserRepository repository.UserRepository + SystemConfigRepo repository.SystemConfigRepository } func NewRouter(deps RouterDependencies) *gin.Engine { @@ -42,13 +44,14 @@ func NewRouter(deps RouterDependencies) *gin.Engine { authHandler := NewAuthHandler(deps.AuthService) systemHandler := NewSystemHandler(deps.SystemService) - storageTargetHandler := NewStorageTargetHandler(deps.StorageTargetService) - backupTaskHandler := NewBackupTaskHandler(deps.BackupTaskService) - backupRunHandler := NewBackupRunHandler(deps.BackupExecutionService) - backupRecordHandler := NewBackupRecordHandler(deps.BackupRecordService) + storageTargetHandler := NewStorageTargetHandler(deps.StorageTargetService, deps.AuditService) + backupTaskHandler := NewBackupTaskHandler(deps.BackupTaskService, deps.AuditService) + backupRunHandler := NewBackupRunHandler(deps.BackupExecutionService, deps.AuditService) + backupRecordHandler := NewBackupRecordHandler(deps.BackupRecordService, deps.AuditService) notificationHandler := NewNotificationHandler(deps.NotificationService) dashboardHandler := NewDashboardHandler(deps.DashboardService) - settingsHandler := NewSettingsHandler(deps.SettingsService) + settingsHandler := NewSettingsHandler(deps.SettingsService, deps.AuditService) + auditHandler := NewAuditHandler(deps.AuditService) api := engine.Group("/api") { @@ -73,6 +76,7 @@ func NewRouter(deps RouterDependencies) *gin.Engine { storageTargets.POST("", storageTargetHandler.Create) storageTargets.PUT("/:id", storageTargetHandler.Update) storageTargets.DELETE("/:id", storageTargetHandler.Delete) + storageTargets.PUT("/:id/star", storageTargetHandler.ToggleStar) storageTargets.POST("/test", storageTargetHandler.TestConnection) storageTargets.POST("/:id/test", storageTargetHandler.TestSavedConnection) storageTargets.GET("/:id/usage", storageTargetHandler.GetUsage) @@ -119,6 +123,17 @@ func NewRouter(deps RouterDependencies) *gin.Engine { settings.GET("", settingsHandler.Get) settings.PUT("", settingsHandler.Update) + auditLogs := api.Group("/audit-logs") + auditLogs.Use(AuthMiddleware(deps.JWTManager)) + auditLogs.GET("", auditHandler.List) + + if deps.DatabaseDiscoveryService != nil { + databaseHandler := NewDatabaseHandler(deps.DatabaseDiscoveryService) + database := api.Group("/database") + database.Use(AuthMiddleware(deps.JWTManager)) + database.POST("/discover", databaseHandler.Discover) + } + nodeHandler := NewNodeHandler(deps.NodeService) nodes := api.Group("/nodes") nodes.Use(AuthMiddleware(deps.JWTManager)) diff --git a/server/internal/http/settings_handler.go b/server/internal/http/settings_handler.go index 42f233b..d52e3ec 100644 --- a/server/internal/http/settings_handler.go +++ b/server/internal/http/settings_handler.go @@ -9,10 +9,11 @@ import ( type SettingsHandler struct { settingsService *service.SettingsService + auditService *service.AuditService } -func NewSettingsHandler(settingsService *service.SettingsService) *SettingsHandler { - return &SettingsHandler{settingsService: settingsService} +func NewSettingsHandler(settingsService *service.SettingsService, auditService *service.AuditService) *SettingsHandler { + return &SettingsHandler{settingsService: settingsService, auditService: auditService} } func (h *SettingsHandler) Get(c *gin.Context) { @@ -35,5 +36,6 @@ func (h *SettingsHandler) Update(c *gin.Context) { response.Error(c, err) return } + recordAudit(c, h.auditService, "settings", "update", "settings", "", "", "") response.Success(c, settings) } diff --git a/server/internal/http/storage_target_handler.go b/server/internal/http/storage_target_handler.go index 0eb1ccf..4c1c2b5 100644 --- a/server/internal/http/storage_target_handler.go +++ b/server/internal/http/storage_target_handler.go @@ -12,7 +12,8 @@ import ( ) type StorageTargetHandler struct { - service *service.StorageTargetService + service *service.StorageTargetService + auditService *service.AuditService } type storageTargetGoogleDriveAuthRequest struct { @@ -27,8 +28,8 @@ type storageTargetGoogleDriveAuthRequest struct { FolderID string `json:"folderId"` } -func NewStorageTargetHandler(service *service.StorageTargetService) *StorageTargetHandler { - return &StorageTargetHandler{service: service} +func NewStorageTargetHandler(service *service.StorageTargetService, auditService *service.AuditService) *StorageTargetHandler { + return &StorageTargetHandler{service: service, auditService: auditService} } func (h *StorageTargetHandler) List(c *gin.Context) { @@ -64,6 +65,7 @@ func (h *StorageTargetHandler) Create(c *gin.Context) { response.Error(c, err) return } + recordAudit(c, h.auditService, "storage_target", "create", "storage_target", fmt.Sprintf("%d", item.ID), item.Name, "") response.Success(c, item) } @@ -82,6 +84,7 @@ func (h *StorageTargetHandler) Update(c *gin.Context) { response.Error(c, err) return } + recordAudit(c, h.auditService, "storage_target", "update", "storage_target", fmt.Sprintf("%d", item.ID), item.Name, "") response.Success(c, item) } @@ -94,6 +97,7 @@ func (h *StorageTargetHandler) Delete(c *gin.Context) { response.Error(c, err) return } + recordAudit(c, h.auditService, "storage_target", "delete", "storage_target", fmt.Sprintf("%d", id), "", "") response.Success(c, gin.H{"deleted": true}) } @@ -230,6 +234,19 @@ func firstNonEmpty(values ...string) string { return "" } +func (h *StorageTargetHandler) ToggleStar(c *gin.Context) { + id, ok := parseUintParam(c, "id") + if !ok { + return + } + item, err := h.service.ToggleStar(c.Request.Context(), id) + if err != nil { + response.Error(c, err) + return + } + response.Success(c, item) +} + func (h *StorageTargetHandler) GetUsage(c *gin.Context) { id, ok := parseUintParam(c, "id") if !ok { diff --git a/server/internal/model/audit_log.go b/server/internal/model/audit_log.go new file mode 100644 index 0000000..92b1e64 --- /dev/null +++ b/server/internal/model/audit_log.go @@ -0,0 +1,21 @@ +package model + +import "time" + +type AuditLog struct { + ID uint `gorm:"primaryKey" json:"id"` + UserID uint `gorm:"column:user_id;index" json:"userId"` + Username string `gorm:"column:username;size:64;not null" json:"username"` + Category string `gorm:"column:category;size:32;index;not null" json:"category"` + Action string `gorm:"column:action;size:64;not null" json:"action"` + TargetType string `gorm:"column:target_type;size:32" json:"targetType"` + TargetID string `gorm:"column:target_id;size:64" json:"targetId"` + TargetName string `gorm:"column:target_name;size:128" json:"targetName"` + Detail string `gorm:"column:detail;type:text" json:"detail"` + ClientIP string `gorm:"column:client_ip;size:45" json:"clientIp"` + CreatedAt time.Time `gorm:"index" json:"createdAt"` +} + +func (AuditLog) TableName() string { + return "audit_logs" +} diff --git a/server/internal/model/backup_record.go b/server/internal/model/backup_record.go index d884d65..5cab79a 100644 --- a/server/internal/model/backup_record.go +++ b/server/internal/model/backup_record.go @@ -9,22 +9,23 @@ const ( ) type BackupRecord struct { - ID uint `gorm:"primaryKey" json:"id"` - TaskID uint `gorm:"column:task_id;index;not null" json:"taskId"` - Task BackupTask `json:"task,omitempty"` - StorageTargetID uint `gorm:"column:storage_target_id;index;not null" json:"storageTargetId"` - StorageTarget StorageTarget `json:"storageTarget,omitempty"` - Status string `gorm:"size:20;index;not null" json:"status"` - FileName string `gorm:"column:file_name;size:255" json:"fileName"` - FileSize int64 `gorm:"column:file_size;not null;default:0" json:"fileSize"` - StoragePath string `gorm:"column:storage_path;size:500" json:"storagePath"` - DurationSeconds int `gorm:"column:duration_seconds;not null;default:0" json:"durationSeconds"` - ErrorMessage string `gorm:"column:error_message;size:2000" json:"errorMessage"` - LogContent string `gorm:"column:log_content;type:text" json:"logContent"` - StartedAt time.Time `gorm:"column:started_at;index;not null" json:"startedAt"` - CompletedAt *time.Time `gorm:"column:completed_at;index" json:"completedAt,omitempty"` - CreatedAt time.Time `json:"createdAt"` - UpdatedAt time.Time `json:"updatedAt"` + ID uint `gorm:"primaryKey" json:"id"` + TaskID uint `gorm:"column:task_id;index;not null" json:"taskId"` + Task BackupTask `json:"task,omitempty"` + StorageTargetID uint `gorm:"column:storage_target_id;index;not null" json:"storageTargetId"` + StorageTarget StorageTarget `json:"storageTarget,omitempty"` + Status string `gorm:"size:20;index;not null" json:"status"` + FileName string `gorm:"column:file_name;size:255" json:"fileName"` + FileSize int64 `gorm:"column:file_size;not null;default:0" json:"fileSize"` + StoragePath string `gorm:"column:storage_path;size:500" json:"storagePath"` + StorageUploadResults string `gorm:"column:storage_upload_results;type:text" json:"-"` + DurationSeconds int `gorm:"column:duration_seconds;not null;default:0" json:"durationSeconds"` + ErrorMessage string `gorm:"column:error_message;size:2000" json:"errorMessage"` + LogContent string `gorm:"column:log_content;type:text" json:"logContent"` + StartedAt time.Time `gorm:"column:started_at;index;not null" json:"startedAt"` + CompletedAt *time.Time `gorm:"column:completed_at;index" json:"completedAt,omitempty"` + CreatedAt time.Time `json:"createdAt"` + UpdatedAt time.Time `json:"updatedAt"` } func (BackupRecord) TableName() string { diff --git a/server/internal/model/backup_task.go b/server/internal/model/backup_task.go index 2b295d4..356a66a 100644 --- a/server/internal/model/backup_task.go +++ b/server/internal/model/backup_task.go @@ -17,34 +17,46 @@ const ( ) type BackupTask struct { - ID uint `gorm:"primaryKey" json:"id"` - Name string `gorm:"size:100;uniqueIndex;not null" json:"name"` - Type string `gorm:"size:20;index;not null" json:"type"` - Enabled bool `gorm:"not null;default:true" json:"enabled"` - CronExpr string `gorm:"column:cron_expr;size:64" json:"cronExpr"` - SourcePath string `gorm:"column:source_path;size:500" json:"sourcePath"` - ExcludePatterns string `gorm:"column:exclude_patterns;type:text" json:"excludePatterns"` - DBHost string `gorm:"column:db_host;size:255" json:"dbHost"` - DBPort int `gorm:"column:db_port" json:"dbPort"` - DBUser string `gorm:"column:db_user;size:100" json:"dbUser"` - DBPasswordCiphertext string `gorm:"column:db_password_ciphertext;type:text" json:"-"` - DBName string `gorm:"column:db_name;size:255" json:"dbName"` - DBPath string `gorm:"column:db_path;size:500" json:"dbPath"` - StorageTargetID uint `gorm:"column:storage_target_id;index;not null" json:"storageTargetId"` - StorageTarget StorageTarget `json:"storageTarget,omitempty"` - NodeID uint `gorm:"column:node_id;index;default:0" json:"nodeId"` - Node Node `json:"node,omitempty"` - Tags string `gorm:"column:tags;size:500" json:"tags"` - RetentionDays int `gorm:"column:retention_days;not null;default:30" json:"retentionDays"` - Compression string `gorm:"size:10;not null;default:'gzip'" json:"compression"` - Encrypt bool `gorm:"not null;default:false" json:"encrypt"` - MaxBackups int `gorm:"column:max_backups;not null;default:10" json:"maxBackups"` - LastRunAt *time.Time `gorm:"column:last_run_at" json:"lastRunAt,omitempty"` - LastStatus string `gorm:"column:last_status;size:20;not null;default:'idle'" json:"lastStatus"` - CreatedAt time.Time `json:"createdAt"` - UpdatedAt time.Time `json:"updatedAt"` + ID uint `gorm:"primaryKey" json:"id"` + Name string `gorm:"size:100;uniqueIndex;not null" json:"name"` + Type string `gorm:"size:20;index;not null" json:"type"` + Enabled bool `gorm:"not null;default:true" json:"enabled"` + CronExpr string `gorm:"column:cron_expr;size:64" json:"cronExpr"` + SourcePath string `gorm:"column:source_path;size:500" json:"sourcePath"` + SourcePaths string `gorm:"column:source_paths;type:text" json:"sourcePaths"` + ExcludePatterns string `gorm:"column:exclude_patterns;type:text" json:"excludePatterns"` + DBHost string `gorm:"column:db_host;size:255" json:"dbHost"` + DBPort int `gorm:"column:db_port" json:"dbPort"` + DBUser string `gorm:"column:db_user;size:100" json:"dbUser"` + DBPasswordCiphertext string `gorm:"column:db_password_ciphertext;type:text" json:"-"` + DBName string `gorm:"column:db_name;size:255" json:"dbName"` + DBPath string `gorm:"column:db_path;size:500" json:"dbPath"` + StorageTargetID uint `gorm:"column:storage_target_id;index;not null" json:"storageTargetId"` // deprecated: 保留兼容 + StorageTarget StorageTarget `json:"storageTarget,omitempty"` // deprecated: 保留兼容 + StorageTargets []StorageTarget `gorm:"many2many:backup_task_storage_targets" json:"storageTargets,omitempty"` + NodeID uint `gorm:"column:node_id;index;default:0" json:"nodeId"` + Node Node `json:"node,omitempty"` + Tags string `gorm:"column:tags;size:500" json:"tags"` + RetentionDays int `gorm:"column:retention_days;not null;default:30" json:"retentionDays"` + Compression string `gorm:"size:10;not null;default:'gzip'" json:"compression"` + Encrypt bool `gorm:"not null;default:false" json:"encrypt"` + MaxBackups int `gorm:"column:max_backups;not null;default:10" json:"maxBackups"` + LastRunAt *time.Time `gorm:"column:last_run_at" json:"lastRunAt,omitempty"` + LastStatus string `gorm:"column:last_status;size:20;not null;default:'idle'" json:"lastStatus"` + CreatedAt time.Time `json:"createdAt"` + UpdatedAt time.Time `json:"updatedAt"` } func (BackupTask) TableName() string { return "backup_tasks" } + +// BackupTaskStorageTarget 多对多中间表 +type BackupTaskStorageTarget struct { + BackupTaskID uint `gorm:"primaryKey;column:backup_task_id"` + StorageTargetID uint `gorm:"primaryKey;column:storage_target_id"` +} + +func (BackupTaskStorageTarget) TableName() string { + return "backup_task_storage_targets" +} diff --git a/server/internal/model/storage_target.go b/server/internal/model/storage_target.go index fd8fb9f..d1ce9a0 100644 --- a/server/internal/model/storage_target.go +++ b/server/internal/model/storage_target.go @@ -8,6 +8,7 @@ type StorageTarget struct { Type string `gorm:"size:32;index;not null" json:"type"` Description string `gorm:"size:255" json:"description"` Enabled bool `gorm:"not null;default:true" json:"enabled"` + Starred bool `gorm:"not null;default:false" json:"starred"` ConfigCiphertext string `gorm:"column:config_ciphertext;type:text;not null" json:"-"` ConfigVersion int `gorm:"not null;default:1" json:"configVersion"` LastTestedAt *time.Time `gorm:"column:last_tested_at" json:"lastTestedAt,omitempty"` diff --git a/server/internal/repository/audit_log_repository.go b/server/internal/repository/audit_log_repository.go new file mode 100644 index 0000000..df6d82d --- /dev/null +++ b/server/internal/repository/audit_log_repository.go @@ -0,0 +1,56 @@ +package repository + +import ( + "context" + + "backupx/server/internal/model" + "gorm.io/gorm" +) + +type AuditLogListOptions struct { + Category string + Limit int + Offset int +} + +type AuditLogListResult struct { + Items []model.AuditLog `json:"items"` + Total int64 `json:"total"` +} + +type AuditLogRepository interface { + Create(ctx context.Context, log *model.AuditLog) error + List(ctx context.Context, opts AuditLogListOptions) (*AuditLogListResult, error) +} + +type gormAuditLogRepository struct { + db *gorm.DB +} + +func NewAuditLogRepository(db *gorm.DB) AuditLogRepository { + return &gormAuditLogRepository{db: db} +} + +func (r *gormAuditLogRepository) Create(_ context.Context, log *model.AuditLog) error { + return r.db.Create(log).Error +} + +func (r *gormAuditLogRepository) List(_ context.Context, opts AuditLogListOptions) (*AuditLogListResult, error) { + query := r.db.Model(&model.AuditLog{}) + if opts.Category != "" { + query = query.Where("category = ?", opts.Category) + } + var total int64 + if err := query.Count(&total).Error; err != nil { + return nil, err + } + limit := opts.Limit + if limit <= 0 { + limit = 50 + } + var items []model.AuditLog + if err := query.Order("created_at DESC").Offset(opts.Offset).Limit(limit).Find(&items).Error; err != nil { + return nil, err + } + return &AuditLogListResult{Items: items, Total: total}, nil +} diff --git a/server/internal/repository/backup_task_repository.go b/server/internal/repository/backup_task_repository.go index 05d0601..2d3467b 100644 --- a/server/internal/repository/backup_task_repository.go +++ b/server/internal/repository/backup_task_repository.go @@ -35,7 +35,7 @@ func NewBackupTaskRepository(db *gorm.DB) *GormBackupTaskRepository { } func (r *GormBackupTaskRepository) List(ctx context.Context, options BackupTaskListOptions) ([]model.BackupTask, error) { - query := r.db.WithContext(ctx).Model(&model.BackupTask{}).Preload("StorageTarget").Order("updated_at desc") + query := r.db.WithContext(ctx).Model(&model.BackupTask{}).Preload("StorageTarget").Preload("StorageTargets").Order("updated_at desc") if options.Type != "" { query = query.Where("type = ?", options.Type) } @@ -51,7 +51,7 @@ func (r *GormBackupTaskRepository) List(ctx context.Context, options BackupTaskL func (r *GormBackupTaskRepository) FindByID(ctx context.Context, id uint) (*model.BackupTask, error) { var item model.BackupTask - if err := r.db.WithContext(ctx).Preload("StorageTarget").First(&item, id).Error; err != nil { + if err := r.db.WithContext(ctx).Preload("StorageTarget").Preload("StorageTargets").First(&item, id).Error; err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { return nil, nil } @@ -73,7 +73,7 @@ func (r *GormBackupTaskRepository) FindByName(ctx context.Context, name string) func (r *GormBackupTaskRepository) ListSchedulable(ctx context.Context) ([]model.BackupTask, error) { var items []model.BackupTask - if err := r.db.WithContext(ctx).Preload("StorageTarget").Where("enabled = ? AND cron_expr <> ''", true).Order("id asc").Find(&items).Error; err != nil { + if err := r.db.WithContext(ctx).Preload("StorageTarget").Preload("StorageTargets").Where("enabled = ? AND cron_expr <> ''", true).Order("id asc").Find(&items).Error; err != nil { return nil, err } return items, nil @@ -97,18 +97,39 @@ func (r *GormBackupTaskRepository) CountEnabled(ctx context.Context) (int64, err func (r *GormBackupTaskRepository) CountByStorageTargetID(ctx context.Context, storageTargetID uint) (int64, error) { var count int64 - if err := r.db.WithContext(ctx).Model(&model.BackupTask{}).Where("storage_target_id = ?", storageTargetID).Count(&count).Error; err != nil { + if err := r.db.WithContext(ctx).Model(&model.BackupTaskStorageTarget{}).Where("storage_target_id = ?", storageTargetID).Count(&count).Error; err != nil { return 0, err } return count, nil } func (r *GormBackupTaskRepository) Create(ctx context.Context, item *model.BackupTask) error { - return r.db.WithContext(ctx).Create(item).Error + if err := r.db.WithContext(ctx).Create(item).Error; err != nil { + return err + } + return r.syncStorageTargets(ctx, item) } func (r *GormBackupTaskRepository) Update(ctx context.Context, item *model.BackupTask) error { - return r.db.WithContext(ctx).Save(item).Error + if err := r.db.WithContext(ctx).Save(item).Error; err != nil { + return err + } + if len(item.StorageTargets) > 0 { + return r.db.WithContext(ctx).Model(item).Association("StorageTargets").Replace(item.StorageTargets) + } + return nil +} + +// syncStorageTargets 确保中间表数据一致:优先使用 StorageTargets,回退到 StorageTargetID +func (r *GormBackupTaskRepository) syncStorageTargets(ctx context.Context, item *model.BackupTask) error { + targets := item.StorageTargets + if len(targets) == 0 && item.StorageTargetID > 0 { + targets = []model.StorageTarget{{ID: item.StorageTargetID}} + } + if len(targets) > 0 { + return r.db.WithContext(ctx).Model(item).Association("StorageTargets").Replace(targets) + } + return nil } func (r *GormBackupTaskRepository) Delete(ctx context.Context, id uint) error { diff --git a/server/internal/repository/storage_target_repository.go b/server/internal/repository/storage_target_repository.go index a206176..792c737 100644 --- a/server/internal/repository/storage_target_repository.go +++ b/server/internal/repository/storage_target_repository.go @@ -27,7 +27,7 @@ func NewStorageTargetRepository(db *gorm.DB) *GormStorageTargetRepository { func (r *GormStorageTargetRepository) List(ctx context.Context) ([]model.StorageTarget, error) { var items []model.StorageTarget - if err := r.db.WithContext(ctx).Order("updated_at desc").Find(&items).Error; err != nil { + if err := r.db.WithContext(ctx).Order("starred desc, updated_at desc").Find(&items).Error; err != nil { return nil, err } return items, nil diff --git a/server/internal/service/audit_service.go b/server/internal/service/audit_service.go new file mode 100644 index 0000000..472295f --- /dev/null +++ b/server/internal/service/audit_service.go @@ -0,0 +1,68 @@ +package service + +import ( + "context" + "fmt" + "log" + + "backupx/server/internal/apperror" + "backupx/server/internal/model" + "backupx/server/internal/repository" +) + +// AuditEntry 是记录审计日志的输入结构 +type AuditEntry struct { + UserID uint + Username string + Category string // auth / storage_target / backup_task / backup_record / settings + Action string // create / update / delete / login_success / login_failed / ... + TargetType string + TargetID string + TargetName string + Detail string + ClientIP string +} + +type AuditService struct { + repo repository.AuditLogRepository +} + +func NewAuditService(repo repository.AuditLogRepository) *AuditService { + return &AuditService{repo: repo} +} + +// Record 异步 fire-and-forget 写入审计日志,不阻塞业务逻辑 +func (s *AuditService) Record(entry AuditEntry) { + if s == nil || s.repo == nil { + return + } + go func() { + record := &model.AuditLog{ + UserID: entry.UserID, + Username: entry.Username, + Category: entry.Category, + Action: entry.Action, + TargetType: entry.TargetType, + TargetID: entry.TargetID, + TargetName: entry.TargetName, + Detail: entry.Detail, + ClientIP: entry.ClientIP, + } + if err := s.repo.Create(context.Background(), record); err != nil { + log.Printf("[audit] failed to write audit log: %v", err) + } + }() +} + +// List 分页查询审计日志 +func (s *AuditService) List(ctx context.Context, category string, limit, offset int) (*repository.AuditLogListResult, error) { + result, err := s.repo.List(ctx, repository.AuditLogListOptions{ + Category: category, + Limit: limit, + Offset: offset, + }) + if err != nil { + return nil, apperror.Internal("AUDIT_LOG_LIST_FAILED", fmt.Sprintf("无法获取审计日志列表: %v", err), err) + } + return result, nil +} diff --git a/server/internal/service/auth_service.go b/server/internal/service/auth_service.go index 2e35e4c..9813529 100644 --- a/server/internal/service/auth_service.go +++ b/server/internal/service/auth_service.go @@ -37,10 +37,11 @@ type UserOutput struct { } type AuthService struct { - users repository.UserRepository - configs repository.SystemConfigRepository - jwtManager *security.JWTManager - rateLimiter *security.LoginRateLimiter + users repository.UserRepository + configs repository.SystemConfigRepository + jwtManager *security.JWTManager + rateLimiter *security.LoginRateLimiter + auditService *AuditService } func NewAuthService( @@ -52,6 +53,10 @@ func NewAuthService( return &AuthService{users: users, configs: configs, jwtManager: jwtManager, rateLimiter: rateLimiter} } +func (s *AuthService) SetAuditService(auditService *AuditService) { + s.auditService = auditService +} + func (s *AuthService) SetupStatus(ctx context.Context) (bool, error) { count, err := s.users.Count(ctx) if err != nil { @@ -97,6 +102,15 @@ func (s *AuthService) Setup(ctx context.Context, input SetupInput) (*AuthPayload return nil, apperror.Internal("AUTH_TOKEN_FAILED", "无法生成访问令牌", err) } + if s.auditService != nil { + s.auditService.Record(AuditEntry{ + UserID: user.ID, Username: user.Username, + Category: "auth", Action: "setup", + TargetType: "user", TargetID: fmt.Sprintf("%d", user.ID), TargetName: user.Username, + Detail: "系统初始化,创建管理员账户", + }) + } + return &AuthPayload{Token: token, User: ToUserOutput(user)}, nil } @@ -113,9 +127,23 @@ func (s *AuthService) Login(ctx context.Context, input LoginInput, clientKey str return nil, apperror.Internal("AUTH_LOOKUP_FAILED", "无法执行登录校验", err) } if user == nil { + if s.auditService != nil { + s.auditService.Record(AuditEntry{ + Category: "auth", Action: "login_failed", + Detail: fmt.Sprintf("用户名不存在: %s", strings.TrimSpace(input.Username)), + ClientIP: clientKey, + }) + } return nil, apperror.Unauthorized("AUTH_INVALID_CREDENTIALS", "用户名或密码错误", nil) } if err := security.ComparePassword(user.PasswordHash, input.Password); err != nil { + if s.auditService != nil { + s.auditService.Record(AuditEntry{ + UserID: user.ID, Username: user.Username, + Category: "auth", Action: "login_failed", + Detail: "密码错误", ClientIP: clientKey, + }) + } return nil, apperror.Unauthorized("AUTH_INVALID_CREDENTIALS", "用户名或密码错误", err) } @@ -124,6 +152,15 @@ func (s *AuthService) Login(ctx context.Context, input LoginInput, clientKey str if err != nil { return nil, apperror.Internal("AUTH_TOKEN_FAILED", "无法生成访问令牌", err) } + + if s.auditService != nil { + s.auditService.Record(AuditEntry{ + UserID: user.ID, Username: user.Username, + Category: "auth", Action: "login_success", + Detail: "登录成功", ClientIP: clientKey, + }) + } + return &AuthPayload{Token: token, User: ToUserOutput(user)}, nil } @@ -170,6 +207,15 @@ func (s *AuthService) ChangePassword(ctx context.Context, subject string, input if err := s.users.Update(ctx, user); err != nil { return apperror.Internal("AUTH_UPDATE_FAILED", "密码修改失败", err) } + + if s.auditService != nil { + s.auditService.Record(AuditEntry{ + UserID: user.ID, Username: user.Username, + Category: "auth", Action: "change_password", + Detail: "密码修改成功", + }) + } + return nil } diff --git a/server/internal/service/backup_execution_service.go b/server/internal/service/backup_execution_service.go index 9f01cc9..6076e22 100644 --- a/server/internal/service/backup_execution_service.go +++ b/server/internal/service/backup_execution_service.go @@ -8,6 +8,7 @@ import ( "os" "path/filepath" "strings" + "sync" "time" "backupx/server/internal/apperror" @@ -37,11 +38,35 @@ func (noopBackupNotifier) NotifyBackupResult(context.Context, BackupExecutionNot return nil } +type StorageUploadResultItem struct { + StorageTargetID uint `json:"storageTargetId"` + StorageTargetName string `json:"storageTargetName"` + Status string `json:"status"` + StoragePath string `json:"storagePath,omitempty"` + FileSize int64 `json:"fileSize,omitempty"` + Error string `json:"error,omitempty"` +} + type DownloadedArtifact struct { FileName string Reader io.ReadCloser } +// collectTargetIDs 获取任务关联的所有存储目标 ID +func collectTargetIDs(task *model.BackupTask) []uint { + if len(task.StorageTargets) > 0 { + ids := make([]uint, len(task.StorageTargets)) + for i, t := range task.StorageTargets { + ids[i] = t.ID + } + return ids + } + if task.StorageTargetID > 0 { + return []uint{task.StorageTargetID} + } + return nil +} + type BackupExecutionService struct { tasks repository.BackupTaskRepository records repository.BackupRecordRepository @@ -194,7 +219,12 @@ func (s *BackupExecutionService) startTask(ctx context.Context, id uint, async b return nil, apperror.New(404, "BACKUP_TASK_NOT_FOUND", "备份任务不存在", fmt.Errorf("backup task %d not found", id)) } startedAt := s.now() - record := &model.BackupRecord{TaskID: task.ID, StorageTargetID: task.StorageTargetID, Status: "running", StartedAt: startedAt} + // 取第一个存储目标 ID 做兼容 + primaryTargetID := task.StorageTargetID + if tids := collectTargetIDs(task); len(tids) > 0 { + primaryTargetID = tids[0] + } + record := &model.BackupRecord{TaskID: task.ID, StorageTargetID: primaryTargetID, Status: "running", StartedAt: startedAt} if err := s.records.Create(ctx, record); err != nil { return nil, apperror.Internal("BACKUP_RECORD_CREATE_FAILED", "无法创建备份记录", err) } @@ -224,10 +254,20 @@ func (s *BackupExecutionService) executeTask(ctx context.Context, task *model.Ba var fileName string var fileSize int64 var storagePath string + var uploadResults []StorageUploadResultItem completeRecord := func() { if finalizeErr := s.finalizeRecord(ctx, task, recordID, startedAt, status, errMessage, logger.String(), fileName, fileSize, storagePath); finalizeErr != nil { logger.Errorf("写回备份记录失败:%v", finalizeErr) } + // 写入多目标上传结果 + if len(uploadResults) > 0 { + if resultsJSON, marshalErr := json.Marshal(uploadResults); marshalErr == nil { + if record, findErr := s.records.FindByID(ctx, recordID); findErr == nil && record != nil { + record.StorageUploadResults = string(resultsJSON) + _ = s.records.Update(ctx, record) + } + } + } if err := s.notifier.NotifyBackupResult(ctx, BackupExecutionNotification{Task: task, Record: &model.BackupRecord{ID: recordID, TaskID: task.ID, Status: status, FileName: fileName, FileSize: fileSize, StoragePath: storagePath, ErrorMessage: errMessage, StartedAt: startedAt}, Error: buildOptionalError(errMessage)}); err != nil { logger.Warnf("发送备份通知失败:%v", err) } @@ -241,12 +281,6 @@ func (s *BackupExecutionService) executeTask(ctx context.Context, task *model.Ba logger.Errorf("构建任务运行时配置失败:%v", err) return } - provider, err := s.resolveProvider(ctx, task.StorageTargetID) - if err != nil { - errMessage = err.Error() - logger.Errorf("创建存储客户端失败:%v", err) - return - } runner, err := s.runnerRegistry.Runner(spec.Type) if err != nil { errMessage = err.Error() @@ -290,31 +324,83 @@ func (s *BackupExecutionService) executeTask(ctx context.Context, task *model.Ba fileSize = info.Size() fileName = filepath.Base(finalPath) storagePath = backup.BuildStorageKey(task.Type, startedAt, fileName) - artifact, err := os.Open(finalPath) - if err != nil { - errMessage = err.Error() - logger.Errorf("打开备份文件失败:%v", err) + + // 收集所有存储目标 + targetIDs := collectTargetIDs(task) + if len(targetIDs) == 0 { + errMessage = "没有关联的存储目标" + logger.Errorf("没有关联的存储目标") return } - defer artifact.Close() - logger.Infof("开始上传备份到存储目标") - if err := provider.Upload(ctx, storagePath, artifact, fileSize, map[string]string{"taskId": fmt.Sprintf("%d", task.ID), "recordId": fmt.Sprintf("%d", recordID)}); err != nil { - errMessage = err.Error() - logger.Errorf("上传备份文件失败:%v", err) - return - } - if s.retention != nil { - cleanupResult, cleanupErr := s.retention.Cleanup(ctx, task, provider) - if cleanupErr != nil { - logger.Warnf("执行保留策略失败:%v", cleanupErr) - } else { - for _, warning := range cleanupResult.Warnings { - logger.Warnf("保留策略警告:%s", warning) + + // 并行上传到所有目标 + uploadResults = make([]StorageUploadResultItem, len(targetIDs)) + var wg sync.WaitGroup + for i, tid := range targetIDs { + wg.Add(1) + go func(index int, targetID uint) { + defer wg.Done() + target, findErr := s.targets.FindByID(ctx, targetID) + targetName := fmt.Sprintf("target-%d", targetID) + if findErr == nil && target != nil { + targetName = target.Name } + provider, resolveErr := s.resolveProvider(ctx, targetID) + if resolveErr != nil { + uploadResults[index] = StorageUploadResultItem{StorageTargetID: targetID, StorageTargetName: targetName, Status: "failed", Error: resolveErr.Error()} + logger.Warnf("存储目标 %s 创建客户端失败:%v", targetName, resolveErr) + return + } + artifact, openErr := os.Open(finalPath) + if openErr != nil { + uploadResults[index] = StorageUploadResultItem{StorageTargetID: targetID, StorageTargetName: targetName, Status: "failed", Error: openErr.Error()} + logger.Warnf("存储目标 %s 打开备份文件失败:%v", targetName, openErr) + return + } + defer artifact.Close() + logger.Infof("开始上传备份到存储目标:%s", targetName) + if uploadErr := provider.Upload(ctx, storagePath, artifact, fileSize, map[string]string{"taskId": fmt.Sprintf("%d", task.ID), "recordId": fmt.Sprintf("%d", recordID)}); uploadErr != nil { + uploadResults[index] = StorageUploadResultItem{StorageTargetID: targetID, StorageTargetName: targetName, Status: "failed", Error: uploadErr.Error()} + logger.Warnf("存储目标 %s 上传失败:%v", targetName, uploadErr) + return + } + uploadResults[index] = StorageUploadResultItem{StorageTargetID: targetID, StorageTargetName: targetName, Status: "success", StoragePath: storagePath, FileSize: fileSize} + logger.Infof("存储目标 %s 上传成功", targetName) + // 每个成功目标独立执行保留策略 + if s.retention != nil { + cleanupResult, cleanupErr := s.retention.Cleanup(ctx, task, provider) + if cleanupErr != nil { + logger.Warnf("存储目标 %s 执行保留策略失败:%v", targetName, cleanupErr) + } else { + for _, warning := range cleanupResult.Warnings { + logger.Warnf("存储目标 %s 保留策略警告:%s", targetName, warning) + } + } + } + }(i, tid) + } + wg.Wait() + + // 汇总结果:任意一个 success → 整体 success + anySuccess := false + var failedMessages []string + for _, r := range uploadResults { + if r.Status == "success" { + anySuccess = true + } else if r.Error != "" { + failedMessages = append(failedMessages, fmt.Sprintf("%s: %s", r.StorageTargetName, r.Error)) } } - status = "success" - logger.Infof("备份执行完成") + if anySuccess { + status = "success" + if len(failedMessages) > 0 { + logger.Warnf("部分存储目标上传失败:%s", strings.Join(failedMessages, "; ")) + } + logger.Infof("备份执行完成") + } else { + errMessage = strings.Join(failedMessages, "; ") + logger.Errorf("所有存储目标上传均失败") + } } func (s *BackupExecutionService) finalizeRecord(ctx context.Context, task *model.BackupTask, recordID uint, startedAt time.Time, status string, errorMessage string, logContent string, fileName string, fileSize int64, storagePath string) error { @@ -376,11 +462,18 @@ func (s *BackupExecutionService) buildTaskSpec(task *model.BackupTask, startedAt } password = string(plain) } + sourcePaths := []string{} + if strings.TrimSpace(task.SourcePaths) != "" { + if err := json.Unmarshal([]byte(task.SourcePaths), &sourcePaths); err != nil { + return backup.TaskSpec{}, apperror.Internal("BACKUP_TASK_DECODE_FAILED", "无法解析源路径配置", err) + } + } return backup.TaskSpec{ ID: task.ID, Name: task.Name, Type: task.Type, SourcePath: task.SourcePath, + SourcePaths: sourcePaths, ExcludePatterns: excludePatterns, StorageTargetID: task.StorageTargetID, StorageTargetType: "", diff --git a/server/internal/service/backup_record_service.go b/server/internal/service/backup_record_service.go index 06a44d5..4a3e53c 100644 --- a/server/internal/service/backup_record_service.go +++ b/server/internal/service/backup_record_service.go @@ -2,6 +2,7 @@ package service import ( "context" + "encoding/json" "strings" "time" @@ -38,8 +39,9 @@ type BackupRecordSummary struct { type BackupRecordDetail struct { BackupRecordSummary - LogContent string `json:"logContent"` - LogEvents []backup.LogEvent `json:"logEvents,omitempty"` + LogContent string `json:"logContent"` + LogEvents []backup.LogEvent `json:"logEvents,omitempty"` + StorageUploadResults []StorageUploadResultItem `json:"storageUploadResults,omitempty"` } type BackupRecordService struct { @@ -130,5 +132,12 @@ func toBackupRecordDetail(item *model.BackupRecord, logHub *backup.LogHub) *Back detail.LogContent = strings.Join(lines, "\n") } } + // 解析多目标上传结果 + if strings.TrimSpace(item.StorageUploadResults) != "" { + var uploadResults []StorageUploadResultItem + if err := json.Unmarshal([]byte(item.StorageUploadResults), &uploadResults); err == nil { + detail.StorageUploadResults = uploadResults + } + } return detail } diff --git a/server/internal/service/backup_task_service.go b/server/internal/service/backup_task_service.go index b74679c..58c5246 100644 --- a/server/internal/service/backup_task_service.go +++ b/server/internal/service/backup_task_service.go @@ -17,23 +17,25 @@ import ( const backupTaskMaskedValue = "********" type BackupTaskUpsertInput struct { - Name string `json:"name" binding:"required,min=1,max=100"` - Type string `json:"type" binding:"required,oneof=file mysql sqlite postgresql pgsql"` - Enabled bool `json:"enabled"` - CronExpr string `json:"cronExpr" binding:"max=64"` - SourcePath string `json:"sourcePath" binding:"max=500"` - ExcludePatterns []string `json:"excludePatterns"` - DBHost string `json:"dbHost" binding:"max=255"` - DBPort int `json:"dbPort"` - DBUser string `json:"dbUser" binding:"max=100"` - DBPassword string `json:"dbPassword" binding:"max=255"` - DBName string `json:"dbName" binding:"max=255"` - DBPath string `json:"dbPath" binding:"max=500"` - StorageTargetID uint `json:"storageTargetId" binding:"required"` - RetentionDays int `json:"retentionDays"` - Compression string `json:"compression" binding:"omitempty,oneof=gzip none"` - Encrypt bool `json:"encrypt"` - MaxBackups int `json:"maxBackups"` + Name string `json:"name" binding:"required,min=1,max=100"` + Type string `json:"type" binding:"required,oneof=file mysql sqlite postgresql pgsql"` + Enabled bool `json:"enabled"` + CronExpr string `json:"cronExpr" binding:"max=64"` + SourcePath string `json:"sourcePath" binding:"max=500"` + SourcePaths []string `json:"sourcePaths"` + ExcludePatterns []string `json:"excludePatterns"` + DBHost string `json:"dbHost" binding:"max=255"` + DBPort int `json:"dbPort"` + DBUser string `json:"dbUser" binding:"max=100"` + DBPassword string `json:"dbPassword" binding:"max=255"` + DBName string `json:"dbName" binding:"max=255"` + DBPath string `json:"dbPath" binding:"max=500"` + StorageTargetID uint `json:"storageTargetId"` // deprecated: 向后兼容 + StorageTargetIDs []uint `json:"storageTargetIds"` // 新增:多存储目标 + RetentionDays int `json:"retentionDays"` + Compression string `json:"compression" binding:"omitempty,oneof=gzip none"` + Encrypt bool `json:"encrypt"` + MaxBackups int `json:"maxBackups"` } type BackupTaskToggleInput struct { @@ -41,25 +43,28 @@ type BackupTaskToggleInput struct { } type BackupTaskSummary struct { - ID uint `json:"id"` - Name string `json:"name"` - Type string `json:"type"` - Enabled bool `json:"enabled"` - CronExpr string `json:"cronExpr"` - StorageTargetID uint `json:"storageTargetId"` - StorageTargetName string `json:"storageTargetName"` - RetentionDays int `json:"retentionDays"` - Compression string `json:"compression"` - Encrypt bool `json:"encrypt"` - MaxBackups int `json:"maxBackups"` - LastRunAt *time.Time `json:"lastRunAt,omitempty"` - LastStatus string `json:"lastStatus"` - UpdatedAt time.Time `json:"updatedAt"` + ID uint `json:"id"` + Name string `json:"name"` + Type string `json:"type"` + Enabled bool `json:"enabled"` + CronExpr string `json:"cronExpr"` + StorageTargetID uint `json:"storageTargetId"` // deprecated: 取第一个 + StorageTargetName string `json:"storageTargetName"` // deprecated: 取第一个 + StorageTargetIDs []uint `json:"storageTargetIds"` + StorageTargetNames []string `json:"storageTargetNames"` + RetentionDays int `json:"retentionDays"` + Compression string `json:"compression"` + Encrypt bool `json:"encrypt"` + MaxBackups int `json:"maxBackups"` + LastRunAt *time.Time `json:"lastRunAt,omitempty"` + LastStatus string `json:"lastStatus"` + UpdatedAt time.Time `json:"updatedAt"` } type BackupTaskDetail struct { BackupTaskSummary SourcePath string `json:"sourcePath"` + SourcePaths []string `json:"sourcePaths"` ExcludePatterns []string `json:"excludePatterns"` DBHost string `json:"dbHost"` DBPort int `json:"dbPort"` @@ -227,19 +232,33 @@ func (s *BackupTaskService) Toggle(ctx context.Context, id uint, enabled bool) ( return &returnValue, nil } +// resolveStorageTargetIDs 统一处理新旧字段,返回有效的存储目标 ID 列表 +func resolveStorageTargetIDs(input BackupTaskUpsertInput) []uint { + if len(input.StorageTargetIDs) > 0 { + return input.StorageTargetIDs + } + if input.StorageTargetID > 0 { + return []uint{input.StorageTargetID} + } + return nil +} + func (s *BackupTaskService) validateInput(ctx context.Context, existing *model.BackupTask, input BackupTaskUpsertInput) error { if strings.TrimSpace(input.Name) == "" { return apperror.BadRequest("BACKUP_TASK_INVALID", "任务名称不能为空", nil) } - if input.StorageTargetID == 0 { - return apperror.BadRequest("BACKUP_TASK_INVALID", "请选择存储目标", nil) + targetIDs := resolveStorageTargetIDs(input) + if len(targetIDs) == 0 { + return apperror.BadRequest("BACKUP_TASK_INVALID", "请选择至少一个存储目标", nil) } - target, err := s.targets.FindByID(ctx, input.StorageTargetID) - if err != nil { - return apperror.Internal("BACKUP_TASK_STORAGE_LOOKUP_FAILED", "无法检查存储目标", err) - } - if target == nil { - return apperror.BadRequest("BACKUP_STORAGE_TARGET_INVALID", "关联的存储目标不存在", nil) + for _, tid := range targetIDs { + target, err := s.targets.FindByID(ctx, tid) + if err != nil { + return apperror.Internal("BACKUP_TASK_STORAGE_LOOKUP_FAILED", "无法检查存储目标", err) + } + if target == nil { + return apperror.BadRequest("BACKUP_STORAGE_TARGET_INVALID", fmt.Sprintf("关联的存储目标 %d 不存在", tid), nil) + } } if input.RetentionDays < 0 { return apperror.BadRequest("BACKUP_TASK_INVALID", "保留天数不能小于 0", nil) @@ -260,7 +279,8 @@ func (s *BackupTaskService) validateInput(ctx context.Context, existing *model.B func validateTaskTypeSpecificFields(input BackupTaskUpsertInput, passwordRequired bool) error { switch normalizeBackupTaskType(input.Type) { case "file": - if strings.TrimSpace(input.SourcePath) == "" { + hasSourcePaths := len(resolveSourcePaths(input)) > 0 + if !hasSourcePaths { return apperror.BadRequest("BACKUP_TASK_INVALID", "文件备份必须填写源路径", nil) } case "mysql", "postgresql": @@ -294,6 +314,10 @@ func (s *BackupTaskService) buildTask(existing *model.BackupTask, input BackupTa if err != nil { return nil, apperror.BadRequest("BACKUP_TASK_INVALID", "排除规则格式不合法", err) } + sourcePathsJSON, err := encodeSourcePaths(resolveSourcePaths(input)) + if err != nil { + return nil, apperror.BadRequest("BACKUP_TASK_INVALID", "源路径格式不合法", err) + } passwordCiphertext := "" if existing != nil { passwordCiphertext = existing.DBPasswordCiphertext @@ -313,12 +337,30 @@ func (s *BackupTaskService) buildTask(existing *model.BackupTask, input BackupTa if maxBackups == 0 { maxBackups = 10 } + targetIDs := resolveStorageTargetIDs(input) + // 保持旧字段兼容:取第一个 + primaryTargetID := uint(0) + if len(targetIDs) > 0 { + primaryTargetID = targetIDs[0] + } + // 构建多对多关联 + storageTargets := make([]model.StorageTarget, len(targetIDs)) + for i, tid := range targetIDs { + storageTargets[i] = model.StorageTarget{ID: tid} + } + // 向后兼容:SourcePath 取第一个 + resolvedPaths := resolveSourcePaths(input) + primarySourcePath := strings.TrimSpace(input.SourcePath) + if len(resolvedPaths) > 0 { + primarySourcePath = resolvedPaths[0] + } item := &model.BackupTask{ Name: strings.TrimSpace(input.Name), Type: normalizeBackupTaskType(input.Type), Enabled: input.Enabled, CronExpr: strings.TrimSpace(input.CronExpr), - SourcePath: strings.TrimSpace(input.SourcePath), + SourcePath: primarySourcePath, + SourcePaths: sourcePathsJSON, ExcludePatterns: excludePatterns, DBHost: strings.TrimSpace(input.DBHost), DBPort: input.DBPort, @@ -326,7 +368,8 @@ func (s *BackupTaskService) buildTask(existing *model.BackupTask, input BackupTa DBPasswordCiphertext: passwordCiphertext, DBName: strings.TrimSpace(input.DBName), DBPath: strings.TrimSpace(input.DBPath), - StorageTargetID: input.StorageTargetID, + StorageTargetID: primaryTargetID, + StorageTargets: storageTargets, RetentionDays: input.RetentionDays, Compression: compression, Encrypt: input.Encrypt, @@ -346,9 +389,14 @@ func (s *BackupTaskService) toDetail(item *model.BackupTask) (*BackupTaskDetail, if err != nil { return nil, apperror.Internal("BACKUP_TASK_DECODE_FAILED", "无法解析备份任务配置", err) } + sourcePaths, err := decodeSourcePaths(item.SourcePaths) + if err != nil { + return nil, apperror.Internal("BACKUP_TASK_DECODE_FAILED", "无法解析源路径配置", err) + } detail := &BackupTaskDetail{ BackupTaskSummary: toBackupTaskSummary(item), SourcePath: item.SourcePath, + SourcePaths: sourcePaths, ExcludePatterns: excludePatterns, DBHost: item.DBHost, DBPort: item.DBPort, @@ -364,25 +412,45 @@ func (s *BackupTaskService) toDetail(item *model.BackupTask) (*BackupTaskDetail, } func toBackupTaskSummary(item *model.BackupTask) BackupTaskSummary { - storageTargetName := "" - if item != nil { - storageTargetName = item.StorageTarget.Name + // 从多对多关联提取 IDs 和 Names + var targetIDs []uint + var targetNames []string + if len(item.StorageTargets) > 0 { + for _, t := range item.StorageTargets { + targetIDs = append(targetIDs, t.ID) + targetNames = append(targetNames, t.Name) + } + } else if item.StorageTargetID > 0 { + // 回退到旧字段 + targetIDs = []uint{item.StorageTargetID} + targetNames = []string{item.StorageTarget.Name} + } + // 向后兼容:取第一个 + primaryID := uint(0) + primaryName := "" + if len(targetIDs) > 0 { + primaryID = targetIDs[0] + } + if len(targetNames) > 0 { + primaryName = targetNames[0] } return BackupTaskSummary{ - ID: item.ID, - Name: item.Name, - Type: normalizeBackupTaskType(item.Type), - Enabled: item.Enabled, - CronExpr: item.CronExpr, - StorageTargetID: item.StorageTargetID, - StorageTargetName: storageTargetName, - RetentionDays: item.RetentionDays, - Compression: item.Compression, - Encrypt: item.Encrypt, - MaxBackups: item.MaxBackups, - LastRunAt: item.LastRunAt, - LastStatus: item.LastStatus, - UpdatedAt: item.UpdatedAt, + ID: item.ID, + Name: item.Name, + Type: normalizeBackupTaskType(item.Type), + Enabled: item.Enabled, + CronExpr: item.CronExpr, + StorageTargetID: primaryID, + StorageTargetName: primaryName, + StorageTargetIDs: targetIDs, + StorageTargetNames: targetNames, + RetentionDays: item.RetentionDays, + Compression: item.Compression, + Encrypt: item.Encrypt, + MaxBackups: item.MaxBackups, + LastRunAt: item.LastRunAt, + LastStatus: item.LastStatus, + UpdatedAt: item.UpdatedAt, } } @@ -408,6 +476,47 @@ func decodeExcludePatterns(value string) ([]string, error) { return items, nil } +// resolveSourcePaths 统一处理 sourcePaths / sourcePath,返回有效路径列表 +func resolveSourcePaths(input BackupTaskUpsertInput) []string { + if len(input.SourcePaths) > 0 { + var cleaned []string + for _, p := range input.SourcePaths { + if trimmed := strings.TrimSpace(p); trimmed != "" { + cleaned = append(cleaned, trimmed) + } + } + if len(cleaned) > 0 { + return cleaned + } + } + if sp := strings.TrimSpace(input.SourcePath); sp != "" { + return []string{sp} + } + return nil +} + +func encodeSourcePaths(paths []string) (string, error) { + if len(paths) == 0 { + return "[]", nil + } + encoded, err := json.Marshal(paths) + if err != nil { + return "", err + } + return string(encoded), nil +} + +func decodeSourcePaths(value string) ([]string, error) { + if strings.TrimSpace(value) == "" || strings.TrimSpace(value) == "[]" { + return []string{}, nil + } + var items []string + if err := json.Unmarshal([]byte(value), &items); err != nil { + return nil, err + } + return items, nil +} + func normalizeBackupTaskType(value string) string { normalized := strings.TrimSpace(strings.ToLower(value)) if normalized == "pgsql" { diff --git a/server/internal/service/database_discovery_service.go b/server/internal/service/database_discovery_service.go new file mode 100644 index 0000000..cb32b4e --- /dev/null +++ b/server/internal/service/database_discovery_service.go @@ -0,0 +1,141 @@ +package service + +import ( + "bytes" + "context" + "fmt" + "strings" + "time" + + "backupx/server/internal/apperror" + "backupx/server/internal/backup" +) + +type DatabaseDiscoverInput struct { + Type string `json:"type" binding:"required,oneof=mysql postgresql"` + Host string `json:"host" binding:"required"` + Port int `json:"port" binding:"required,min=1"` + User string `json:"user" binding:"required"` + Password string `json:"password" binding:"required"` +} + +type DatabaseDiscoverResult struct { + Databases []string `json:"databases"` +} + +type DatabaseDiscoveryService struct { + executor backup.CommandExecutor +} + +func NewDatabaseDiscoveryService(executor backup.CommandExecutor) *DatabaseDiscoveryService { + return &DatabaseDiscoveryService{executor: executor} +} + +func (s *DatabaseDiscoveryService) Discover(ctx context.Context, input DatabaseDiscoverInput) (*DatabaseDiscoverResult, error) { + switch strings.TrimSpace(strings.ToLower(input.Type)) { + case "mysql": + return s.discoverMySQL(ctx, input) + case "postgresql": + return s.discoverPostgreSQL(ctx, input) + default: + return nil, apperror.BadRequest("DATABASE_DISCOVER_INVALID_TYPE", "不支持的数据库类型", nil) + } +} + +func (s *DatabaseDiscoveryService) discoverMySQL(ctx context.Context, input DatabaseDiscoverInput) (*DatabaseDiscoverResult, error) { + mysqlPath, err := s.executor.LookPath("mysql") + if err != nil { + return nil, apperror.BadRequest("DATABASE_DISCOVER_MYSQL_NOT_FOUND", "系统未安装 mysql 客户端", err) + } + + timeout, cancel := context.WithTimeout(ctx, 5*time.Second) + defer cancel() + + var stdout, stderr bytes.Buffer + args := []string{ + fmt.Sprintf("--host=%s", input.Host), + fmt.Sprintf("--port=%d", input.Port), + fmt.Sprintf("--user=%s", input.User), + "-e", "SHOW DATABASES", + "--skip-column-names", + } + env := []string{fmt.Sprintf("MYSQL_PWD=%s", input.Password)} + + if err := s.executor.Run(timeout, mysqlPath, args, backup.CommandOptions{ + Stdout: &stdout, + Stderr: &stderr, + Env: env, + }); err != nil { + errMsg := strings.TrimSpace(stderr.String()) + if errMsg == "" { + errMsg = err.Error() + } + return nil, apperror.BadRequest("DATABASE_DISCOVER_MYSQL_FAILED", fmt.Sprintf("连接 MySQL 失败:%s", sanitizeMessage(errMsg)), err) + } + + systemDBs := map[string]bool{ + "information_schema": true, + "performance_schema": true, + "mysql": true, + "sys": true, + } + + var databases []string + for _, line := range strings.Split(stdout.String(), "\n") { + db := strings.TrimSpace(line) + if db == "" || systemDBs[db] { + continue + } + databases = append(databases, db) + } + + return &DatabaseDiscoverResult{Databases: databases}, nil +} + +func (s *DatabaseDiscoveryService) discoverPostgreSQL(ctx context.Context, input DatabaseDiscoverInput) (*DatabaseDiscoverResult, error) { + psqlPath, err := s.executor.LookPath("psql") + if err != nil { + return nil, apperror.BadRequest("DATABASE_DISCOVER_PSQL_NOT_FOUND", "系统未安装 psql 客户端", err) + } + + timeout, cancel := context.WithTimeout(ctx, 5*time.Second) + defer cancel() + + var stdout, stderr bytes.Buffer + args := []string{ + "-h", input.Host, + "-p", fmt.Sprintf("%d", input.Port), + "-U", input.User, + "-d", "postgres", + "-t", "-A", + "-c", "SELECT datname FROM pg_database WHERE datistemplate = false ORDER BY datname", + } + env := []string{fmt.Sprintf("PGPASSWORD=%s", input.Password)} + + if err := s.executor.Run(timeout, psqlPath, args, backup.CommandOptions{ + Stdout: &stdout, + Stderr: &stderr, + Env: env, + }); err != nil { + errMsg := strings.TrimSpace(stderr.String()) + if errMsg == "" { + errMsg = err.Error() + } + return nil, apperror.BadRequest("DATABASE_DISCOVER_PSQL_FAILED", fmt.Sprintf("连接 PostgreSQL 失败:%s", sanitizeMessage(errMsg)), err) + } + + skipDBs := map[string]bool{ + "postgres": true, + } + + var databases []string + for _, line := range strings.Split(stdout.String(), "\n") { + db := strings.TrimSpace(line) + if db == "" || skipDBs[db] || strings.HasPrefix(db, "template") { + continue + } + databases = append(databases, db) + } + + return &DatabaseDiscoverResult{Databases: databases}, nil +} diff --git a/server/internal/service/storage_target_service.go b/server/internal/service/storage_target_service.go index 0ac209e..ec6a0f3 100644 --- a/server/internal/service/storage_target_service.go +++ b/server/internal/service/storage_target_service.go @@ -53,6 +53,7 @@ type StorageTargetSummary struct { Type string `json:"type"` Description string `json:"description"` Enabled bool `json:"enabled"` + Starred bool `json:"starred"` ConfigVersion int `json:"configVersion"` LastTestedAt *time.Time `json:"lastTestedAt"` LastTestStatus string `json:"lastTestStatus"` @@ -209,6 +210,22 @@ func (s *StorageTargetService) Delete(ctx context.Context, id uint) error { return nil } +func (s *StorageTargetService) ToggleStar(ctx context.Context, id uint) (*StorageTargetSummary, error) { + item, err := s.targets.FindByID(ctx, id) + if err != nil { + return nil, apperror.Internal("STORAGE_TARGET_GET_FAILED", "无法获取存储目标详情", err) + } + if item == nil { + return nil, apperror.New(http.StatusNotFound, "STORAGE_TARGET_NOT_FOUND", "存储目标不存在", fmt.Errorf("storage target %d not found", id)) + } + item.Starred = !item.Starred + if err := s.targets.Update(ctx, item); err != nil { + return nil, apperror.Internal("STORAGE_TARGET_UPDATE_FAILED", "无法更新存储目标收藏状态", err) + } + summary := toStorageTargetSummary(item) + return &summary, nil +} + func (s *StorageTargetService) TestConnection(ctx context.Context, input StorageTargetTestInput) error { item, err := s.buildStorageTargetForTest(ctx, input) if err != nil { @@ -493,6 +510,7 @@ func toStorageTargetSummary(item *model.StorageTarget) StorageTargetSummary { Type: item.Type, Description: item.Description, Enabled: item.Enabled, + Starred: item.Starred, ConfigVersion: item.ConfigVersion, LastTestedAt: item.LastTestedAt, LastTestStatus: item.LastTestStatus, diff --git a/web/src/components/backup-records/BackupRecordLogDrawer.tsx b/web/src/components/backup-records/BackupRecordLogDrawer.tsx index 2f7612a..ca38a79 100644 --- a/web/src/components/backup-records/BackupRecordLogDrawer.tsx +++ b/web/src/components/backup-records/BackupRecordLogDrawer.tsx @@ -1,7 +1,7 @@ import { Alert, Button, Descriptions, Drawer, Space, Spin, Tag, Typography } from '@arco-design/web-react' import { useEffect, useMemo, useState } from 'react' import { deleteBackupRecord, downloadBackupRecord, getBackupRecord, restoreBackupRecord, streamBackupRecordLogs } from '../../services/backup-records' -import type { BackupLogEvent, BackupRecordDetail, BackupRecordStatus } from '../../types/backup-records' +import type { BackupLogEvent, BackupRecordDetail, BackupRecordStatus, StorageUploadResultItem } from '../../types/backup-records' import { resolveErrorMessage } from '../../utils/error' import { formatBytes, formatDateTime, formatDuration } from '../../utils/format' @@ -221,6 +221,19 @@ export function BackupRecordLogDrawer({ visible, recordId, onCancel, onChanged } 删除 + {record.storageUploadResults && record.storageUploadResults.length > 1 && ( +