diff --git a/server/internal/app/app.go b/server/internal/app/app.go index 5a361a4..6542e4f 100644 --- a/server/internal/app/app.go +++ b/server/internal/app/app.go @@ -79,6 +79,7 @@ func New(ctx context.Context, cfg config.Config, version string) (*Application, storageTargetService.SetBackupTaskRepository(backupTaskRepo) storageTargetService.SetBackupRecordRepository(backupRecordRepo) backupTaskService := service.NewBackupTaskService(backupTaskRepo, storageTargetRepo, configCipher) + backupTaskService.SetRecordsAndStorage(backupRecordRepo, storageRegistry) backupRunnerRegistry := backup.NewRegistry(backup.NewFileRunner(), backup.NewSQLiteRunner(), backup.NewMySQLRunner(nil), backup.NewPostgreSQLRunner(nil), backup.NewSAPHANARunner(nil)) logHub := backup.NewLogHub() retentionService := backupretention.NewService(backupRecordRepo) @@ -110,7 +111,7 @@ func New(ctx context.Context, cfg config.Config, version string) (*Application, // Cluster: Node management nodeRepo := repository.NewNodeRepository(db) - nodeService := service.NewNodeService(nodeRepo) + nodeService := service.NewNodeService(nodeRepo, version) if err := nodeService.EnsureLocalNode(ctx); err != nil { appLogger.Warn("failed to ensure local node", zap.Error(err)) } diff --git a/server/internal/backup/retention/service.go b/server/internal/backup/retention/service.go index 5413c93..b9393f2 100644 --- a/server/internal/backup/retention/service.go +++ b/server/internal/backup/retention/service.go @@ -11,6 +11,28 @@ import ( "backupx/server/internal/storage" ) +// collectDirPrefixes 从待删除的记录中提取唯一的父目录前缀。 +func collectDirPrefixes(records []model.BackupRecord) []string { + seen := make(map[string]struct{}) + var prefixes []string + for _, record := range records { + path := strings.TrimSpace(record.StoragePath) + if path == "" { + continue + } + idx := strings.LastIndex(path, "/") + if idx <= 0 { + continue + } + dir := path[:idx] + if _, ok := seen[dir]; !ok { + seen[dir] = struct{}{} + prefixes = append(prefixes, dir) + } + } + return prefixes +} + type CleanupResult struct { DeletedRecords int DeletedObjects int @@ -54,6 +76,17 @@ func (s *Service) Cleanup(ctx context.Context, task *model.BackupTask, provider } result.DeletedRecords++ } + + // 清理空目录:收集被删除文件的父目录,尝试移除空目录 + if dirCleaner, ok := provider.(storage.StorageDirCleaner); ok && result.DeletedObjects > 0 { + prefixes := collectDirPrefixes(candidates) + for _, prefix := range prefixes { + if err := dirCleaner.RemoveEmptyDirs(ctx, prefix); err != nil { + result.Warnings = append(result.Warnings, fmt.Sprintf("cleanup empty dirs for %s: %v", prefix, err)) + } + } + } + return result, nil } diff --git a/server/internal/backup/retention/service_test.go b/server/internal/backup/retention/service_test.go index 27c1fc1..e1e2014 100644 --- a/server/internal/backup/retention/service_test.go +++ b/server/internal/backup/retention/service_test.go @@ -36,6 +36,9 @@ func (r *fakeRecordRepository) Delete(_ context.Context, id uint) error { func (r *fakeRecordRepository) ListRecent(context.Context, int) ([]model.BackupRecord, error) { return nil, nil } +func (r *fakeRecordRepository) ListByTask(_ context.Context, _ uint) ([]model.BackupRecord, error) { + return r.records, nil +} func (r *fakeRecordRepository) ListSuccessfulByTask(_ context.Context, _ uint) ([]model.BackupRecord, error) { return r.records, nil } diff --git a/server/internal/http/backup_record_handler.go b/server/internal/http/backup_record_handler.go index d3eca44..a312125 100644 --- a/server/internal/http/backup_record_handler.go +++ b/server/internal/http/backup_record_handler.go @@ -130,7 +130,8 @@ 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), "", fmt.Sprintf("恢复备份记录 #%d", id)) + recordAudit(c, h.auditService, "backup_record", "restore", "backup_record", fmt.Sprintf("%d", id), "", + fmt.Sprintf("恢复备份记录 (ID: %d)", id)) response.Success(c, gin.H{"restored": true}) } @@ -143,7 +144,8 @@ 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), "", fmt.Sprintf("删除备份记录 #%d", id)) + recordAudit(c, h.auditService, "backup_record", "delete", "backup_record", fmt.Sprintf("%d", id), "", + fmt.Sprintf("删除备份记录 (ID: %d)", id)) response.Success(c, gin.H{"deleted": true}) } diff --git a/server/internal/http/backup_task_handler.go b/server/internal/http/backup_task_handler.go index 2534ca8..487a022 100644 --- a/server/internal/http/backup_task_handler.go +++ b/server/internal/http/backup_task_handler.go @@ -14,6 +14,19 @@ type BackupTaskHandler struct { auditService *service.AuditService } +// describeTaskInput 提取审计日志中通用的调度和存储目标描述。 +func describeTaskInput(input service.BackupTaskUpsertInput) (cronDesc string, storageCount int) { + cronDesc = "仅手动执行" + if input.CronExpr != "" { + cronDesc = input.CronExpr + } + storageCount = len(input.StorageTargetIDs) + if storageCount == 0 && input.StorageTargetID > 0 { + storageCount = 1 + } + return +} + func NewBackupTaskHandler(taskService *service.BackupTaskService, auditService *service.AuditService) *BackupTaskHandler { return &BackupTaskHandler{service: taskService, auditService: auditService} } @@ -51,7 +64,9 @@ 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, fmt.Sprintf("类型: %s", input.Type)) + cronDesc, storageCount := describeTaskInput(input) + recordAudit(c, h.auditService, "backup_task", "create", "backup_task", fmt.Sprintf("%d", item.ID), item.Name, + fmt.Sprintf("创建备份任务「%s」,类型: %s, 调度: %s, 存储: %d 个目标", item.Name, input.Type, cronDesc, storageCount)) response.Success(c, item) } @@ -70,7 +85,9 @@ 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, fmt.Sprintf("类型: %s, Cron: %s", input.Type, input.CronExpr)) + updCronDesc, updStorageCount := describeTaskInput(input) + recordAudit(c, h.auditService, "backup_task", "update", "backup_task", fmt.Sprintf("%d", item.ID), item.Name, + fmt.Sprintf("更新备份任务「%s」,类型: %s, 调度: %s, 存储: %d 个目标", item.Name, input.Type, updCronDesc, updStorageCount)) response.Success(c, item) } @@ -79,11 +96,13 @@ func (h *BackupTaskHandler) Delete(c *gin.Context) { if !ok { return } - if err := h.service.Delete(c.Request.Context(), id); err != nil { + result, err := h.service.Delete(c.Request.Context(), id) + if err != nil { response.Error(c, err) return } - recordAudit(c, h.auditService, "backup_task", "delete", "backup_task", fmt.Sprintf("%d", id), "", fmt.Sprintf("删除备份任务 #%d", id)) + recordAudit(c, h.auditService, "backup_task", "delete", "backup_task", fmt.Sprintf("%d", id), result.TaskName, + fmt.Sprintf("删除备份任务「%s」(ID: %d),关联记录 %d 条,已清理远端文件 %d 个", result.TaskName, id, result.RecordCount, result.CleanedFiles)) response.Success(c, gin.H{"deleted": true}) } @@ -112,9 +131,12 @@ func (h *BackupTaskHandler) Toggle(c *gin.Context) { return } action := "enable" + actionLabel := "启用" if !enabled { action = "disable" + actionLabel = "停用" } - recordAudit(c, h.auditService, "backup_task", action, "backup_task", fmt.Sprintf("%d", id), item.Name, fmt.Sprintf("%s 备份任务", action)) + recordAudit(c, h.auditService, "backup_task", action, "backup_task", fmt.Sprintf("%d", id), item.Name, + fmt.Sprintf("%s备份任务「%s」", actionLabel, item.Name)) response.Success(c, item) } diff --git a/server/internal/http/node_handler.go b/server/internal/http/node_handler.go index 4f8c4aa..f915a6c 100644 --- a/server/internal/http/node_handler.go +++ b/server/internal/http/node_handler.go @@ -1,6 +1,7 @@ package http import ( + "fmt" stdhttp "net/http" "strconv" @@ -10,11 +11,12 @@ import ( ) type NodeHandler struct { - service *service.NodeService + service *service.NodeService + auditService *service.AuditService } -func NewNodeHandler(service *service.NodeService) *NodeHandler { - return &NodeHandler{service: service} +func NewNodeHandler(service *service.NodeService, auditService *service.AuditService) *NodeHandler { + return &NodeHandler{service: service, auditService: auditService} } func (h *NodeHandler) List(c *gin.Context) { @@ -51,6 +53,8 @@ func (h *NodeHandler) Create(c *gin.Context) { response.Error(c, err) return } + recordAudit(c, h.auditService, "node", "create", "node", "", input.Name, + fmt.Sprintf("创建远程节点「%s」", input.Name)) response.Success(c, gin.H{"token": token}) } @@ -64,6 +68,8 @@ func (h *NodeHandler) Delete(c *gin.Context) { response.Error(c, err) return } + recordAudit(c, h.auditService, "node", "delete", "node", fmt.Sprintf("%d", id), "", + fmt.Sprintf("删除节点 (ID: %d)", id)) response.Success(c, nil) } @@ -82,18 +88,41 @@ func (h *NodeHandler) ListDirectory(c *gin.Context) { response.Success(c, entries) } +func (h *NodeHandler) Update(c *gin.Context) { + id, err := strconv.ParseUint(c.Param("id"), 10, 32) + if err != nil { + response.Error(c, err) + return + } + var input service.NodeUpdateInput + if err := c.ShouldBindJSON(&input); err != nil { + c.JSON(stdhttp.StatusBadRequest, gin.H{"code": "INVALID_INPUT", "message": err.Error()}) + return + } + item, err := h.service.Update(c.Request.Context(), uint(id), input) + if err != nil { + response.Error(c, err) + return + } + recordAudit(c, h.auditService, "node", "update", "node", fmt.Sprintf("%d", id), item.Name, + fmt.Sprintf("更新节点「%s」(ID: %d)", item.Name, id)) + response.Success(c, item) +} + func (h *NodeHandler) Heartbeat(c *gin.Context) { var input struct { Token string `json:"token" binding:"required"` Hostname string `json:"hostname"` IPAddress string `json:"ipAddress"` AgentVersion string `json:"agentVersion"` + OS string `json:"os"` + Arch string `json:"arch"` } if err := c.ShouldBindJSON(&input); err != nil { c.JSON(stdhttp.StatusBadRequest, gin.H{"code": "INVALID_INPUT", "message": err.Error()}) return } - if err := h.service.Heartbeat(c.Request.Context(), input.Token, input.Hostname, input.IPAddress, input.AgentVersion); err != nil { + if err := h.service.Heartbeat(c.Request.Context(), input.Token, input.Hostname, input.IPAddress, input.AgentVersion, input.OS, input.Arch); err != nil { response.Error(c, err) return } diff --git a/server/internal/http/router.go b/server/internal/http/router.go index 12df699..21b55fa 100644 --- a/server/internal/http/router.go +++ b/server/internal/http/router.go @@ -69,7 +69,6 @@ func NewRouter(deps RouterDependencies) *gin.Engine { system.Use(AuthMiddleware(deps.JWTManager)) system.GET("/info", systemHandler.Info) system.GET("/update-check", systemHandler.CheckUpdate) - system.POST("/update-apply", systemHandler.ApplyUpdate) storageTargets := api.Group("/storage-targets") storageTargets.Use(AuthMiddleware(deps.JWTManager)) @@ -141,12 +140,13 @@ func NewRouter(deps RouterDependencies) *gin.Engine { database.POST("/discover", databaseHandler.Discover) } - nodeHandler := NewNodeHandler(deps.NodeService) + nodeHandler := NewNodeHandler(deps.NodeService, deps.AuditService) nodes := api.Group("/nodes") nodes.Use(AuthMiddleware(deps.JWTManager)) nodes.GET("", nodeHandler.List) nodes.GET("/:id", nodeHandler.Get) nodes.POST("", nodeHandler.Create) + nodes.PUT("/:id", nodeHandler.Update) nodes.DELETE("/:id", nodeHandler.Delete) nodes.GET("/:id/fs/list", nodeHandler.ListDirectory) diff --git a/server/internal/http/storage_target_handler.go b/server/internal/http/storage_target_handler.go index 3a18c48..1a9c99a 100644 --- a/server/internal/http/storage_target_handler.go +++ b/server/internal/http/storage_target_handler.go @@ -65,7 +65,8 @@ 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, fmt.Sprintf("类型: %s", input.Type)) + recordAudit(c, h.auditService, "storage_target", "create", "storage_target", fmt.Sprintf("%d", item.ID), item.Name, + fmt.Sprintf("创建存储目标「%s」,类型: %s", item.Name, input.Type)) response.Success(c, item) } @@ -84,7 +85,8 @@ 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, fmt.Sprintf("类型: %s", input.Type)) + recordAudit(c, h.auditService, "storage_target", "update", "storage_target", fmt.Sprintf("%d", item.ID), item.Name, + fmt.Sprintf("更新存储目标「%s」,类型: %s", item.Name, input.Type)) response.Success(c, item) } @@ -97,7 +99,8 @@ 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), "", fmt.Sprintf("删除存储目标 #%d", id)) + recordAudit(c, h.auditService, "storage_target", "delete", "storage_target", fmt.Sprintf("%d", id), "", + fmt.Sprintf("删除存储目标 (ID: %d)", id)) response.Success(c, gin.H{"deleted": true}) } diff --git a/server/internal/http/system_handler.go b/server/internal/http/system_handler.go index cb723c6..206b99b 100644 --- a/server/internal/http/system_handler.go +++ b/server/internal/http/system_handler.go @@ -18,15 +18,6 @@ func (h *SystemHandler) Info(c *gin.Context) { response.Success(c, h.systemService.GetInfo(c.Request.Context())) } -func (h *SystemHandler) ApplyUpdate(c *gin.Context) { - var input struct { - Version string `json:"version"` - } - _ = c.ShouldBindJSON(&input) - result := h.systemService.ApplyDockerUpdate(c.Request.Context(), input.Version) - response.Success(c, result) -} - func (h *SystemHandler) CheckUpdate(c *gin.Context) { result, err := h.systemService.CheckUpdate(c.Request.Context()) if err != nil { diff --git a/server/internal/repository/backup_record_repository.go b/server/internal/repository/backup_record_repository.go index 9ff3eb5..5752501 100644 --- a/server/internal/repository/backup_record_repository.go +++ b/server/internal/repository/backup_record_repository.go @@ -37,6 +37,7 @@ type BackupRecordRepository interface { Update(context.Context, *model.BackupRecord) error Delete(context.Context, uint) error ListRecent(context.Context, int) ([]model.BackupRecord, error) + ListByTask(context.Context, uint) ([]model.BackupRecord, error) ListSuccessfulByTask(context.Context, uint) ([]model.BackupRecord, error) Count(context.Context) (int64, error) CountSince(context.Context, time.Time) (int64, error) @@ -115,6 +116,14 @@ func (r *GormBackupRecordRepository) ListRecent(ctx context.Context, limit int) return items, nil } +func (r *GormBackupRecordRepository) ListByTask(ctx context.Context, taskID uint) ([]model.BackupRecord, error) { + var items []model.BackupRecord + if err := r.db.WithContext(ctx).Where("task_id = ?", taskID).Order("id desc").Find(&items).Error; err != nil { + return nil, err + } + return items, nil +} + func (r *GormBackupRecordRepository) ListSuccessfulByTask(ctx context.Context, taskID uint) ([]model.BackupRecord, error) { var items []model.BackupRecord if err := r.db.WithContext(ctx).Where("task_id = ? AND status = ?", taskID, "success").Order("completed_at desc, id desc").Find(&items).Error; err != nil { diff --git a/server/internal/service/backup_task_service.go b/server/internal/service/backup_task_service.go index 58c5246..194adcf 100644 --- a/server/internal/service/backup_task_service.go +++ b/server/internal/service/backup_task_service.go @@ -11,6 +11,7 @@ import ( "backupx/server/internal/apperror" "backupx/server/internal/model" "backupx/server/internal/repository" + "backupx/server/internal/storage" "backupx/server/internal/storage/codec" ) @@ -81,10 +82,12 @@ type BackupTaskScheduler interface { } type BackupTaskService struct { - tasks repository.BackupTaskRepository - targets repository.StorageTargetRepository - cipher *codec.ConfigCipher - scheduler BackupTaskScheduler + tasks repository.BackupTaskRepository + targets repository.StorageTargetRepository + records repository.BackupRecordRepository + storageRegistry *storage.Registry + cipher *codec.ConfigCipher + scheduler BackupTaskScheduler } func NewBackupTaskService( @@ -95,6 +98,12 @@ func NewBackupTaskService( return &BackupTaskService{tasks: tasks, targets: targets, cipher: cipher} } +// SetRecordsAndStorage 注入备份记录仓库和存储注册表,用于任务删除时清理远端文件。 +func (s *BackupTaskService) SetRecordsAndStorage(records repository.BackupRecordRepository, registry *storage.Registry) { + s.records = records + s.storageRegistry = registry +} + func (s *BackupTaskService) SetScheduler(scheduler BackupTaskScheduler) { s.scheduler = scheduler } @@ -185,26 +194,80 @@ func (s *BackupTaskService) Update(ctx context.Context, id uint, input BackupTas return s.Get(ctx, item.ID) } -func (s *BackupTaskService) Delete(ctx context.Context, id uint) error { +// DeleteResult 描述任务删除的结果信息,用于审计日志。 +type DeleteResult struct { + TaskName string + RecordCount int + CleanedFiles int +} + +func (s *BackupTaskService) Delete(ctx context.Context, id uint) (*DeleteResult, error) { existing, err := s.tasks.FindByID(ctx, id) if err != nil { - return apperror.Internal("BACKUP_TASK_GET_FAILED", "无法获取备份任务详情", err) + return nil, apperror.Internal("BACKUP_TASK_GET_FAILED", "无法获取备份任务详情", err) } if existing == nil { - return apperror.New(http.StatusNotFound, "BACKUP_TASK_NOT_FOUND", "备份任务不存在", fmt.Errorf("backup task %d not found", id)) - } - if s.scheduler != nil { - if err := s.scheduler.RemoveTask(ctx, id); err != nil { - return apperror.Internal("BACKUP_TASK_SCHEDULE_FAILED", "无法移除备份任务调度", err) - } - } - if err := s.tasks.Delete(ctx, id); err != nil { - return apperror.Internal("BACKUP_TASK_DELETE_FAILED", "无法删除备份任务", err) + return nil, apperror.New(http.StatusNotFound, "BACKUP_TASK_NOT_FOUND", "备份任务不存在", fmt.Errorf("backup task %d not found", id)) } if s.scheduler != nil { _ = s.scheduler.RemoveTask(ctx, id) } - return nil + + // 清理远端存储文件(尽力而为,不阻止删除) + result := &DeleteResult{TaskName: existing.Name} + result.RecordCount, result.CleanedFiles = s.cleanupRemoteFiles(ctx, id) + + if err := s.tasks.Delete(ctx, id); err != nil { + return nil, apperror.Internal("BACKUP_TASK_DELETE_FAILED", "无法删除备份任务", err) + } + return result, nil +} + +// cleanupRemoteFiles 尽力删除任务相关的远端备份文件,返回记录数和成功删除的文件数。 +func (s *BackupTaskService) cleanupRemoteFiles(ctx context.Context, taskID uint) (recordCount int, cleanedFiles int) { + if s.records == nil || s.storageRegistry == nil { + return 0, 0 + } + records, err := s.records.ListByTask(ctx, taskID) + if err != nil { + return 0, 0 + } + recordCount = len(records) + // 缓存 provider 避免同一存储目标重复创建连接 + providerCache := make(map[uint]storage.StorageProvider) + for _, record := range records { + if strings.TrimSpace(record.StoragePath) == "" { + continue + } + provider, ok := providerCache[record.StorageTargetID] + if !ok { + provider, err = s.resolveStorageProvider(ctx, record.StorageTargetID) + if err != nil { + continue + } + providerCache[record.StorageTargetID] = provider + } + if err := provider.Delete(ctx, record.StoragePath); err == nil { + cleanedFiles++ + } + } + return recordCount, cleanedFiles +} + +func (s *BackupTaskService) resolveStorageProvider(ctx context.Context, targetID uint) (storage.StorageProvider, error) { + target, err := s.targets.FindByID(ctx, targetID) + if err != nil || target == nil { + return nil, fmt.Errorf("target %d not found", targetID) + } + configMap := map[string]any{} + if err := s.cipher.DecryptJSON(target.ConfigCiphertext, &configMap); err != nil { + return nil, err + } + provider, err := s.storageRegistry.Create(ctx, target.Type, configMap) + if err != nil { + return nil, err + } + return provider, nil } func (s *BackupTaskService) Toggle(ctx context.Context, id uint, enabled bool) (*BackupTaskSummary, error) { diff --git a/server/internal/service/node_service.go b/server/internal/service/node_service.go index ef1782a..260d45a 100644 --- a/server/internal/service/node_service.go +++ b/server/internal/service/node_service.go @@ -5,11 +5,13 @@ import ( "crypto/rand" "encoding/hex" "fmt" + "net" "net/http" "os" "path/filepath" "runtime" "sort" + "strings" "time" "backupx/server/internal/apperror" @@ -37,13 +39,19 @@ type NodeCreateInput struct { Name string `json:"name" binding:"required"` } -// NodeService manages the cluster nodes. -type NodeService struct { - repo repository.NodeRepository +// NodeUpdateInput 是编辑节点的输入。 +type NodeUpdateInput struct { + Name string `json:"name" binding:"required"` } -func NewNodeService(repo repository.NodeRepository) *NodeService { - return &NodeService{repo: repo} +// NodeService manages the cluster nodes. +type NodeService struct { + repo repository.NodeRepository + version string +} + +func NewNodeService(repo repository.NodeRepository, version string) *NodeService { + return &NodeService{repo: repo, version: version} } // EnsureLocalNode creates the default "local" node if it does not exist. @@ -57,6 +65,8 @@ func (s *NodeService) EnsureLocalNode(ctx context.Context) error { existing.LastSeen = time.Now().UTC() hostname, _ := os.Hostname() existing.Hostname = hostname + existing.IPAddress = detectLocalIP() + existing.AgentVer = s.version existing.OS = runtime.GOOS existing.Arch = runtime.GOARCH return s.repo.Update(ctx, existing) @@ -64,14 +74,16 @@ func (s *NodeService) EnsureLocalNode(ctx context.Context) error { hostname, _ := os.Hostname() token, _ := generateToken() node := &model.Node{ - Name: "本机 (Local)", - Hostname: hostname, - Token: token, - Status: model.NodeStatusOnline, - IsLocal: true, - OS: runtime.GOOS, - Arch: runtime.GOARCH, - LastSeen: time.Now().UTC(), + Name: "本机 (Local)", + Hostname: hostname, + IPAddress: detectLocalIP(), + Token: token, + Status: model.NodeStatusOnline, + IsLocal: true, + OS: runtime.GOOS, + Arch: runtime.GOARCH, + AgentVer: s.version, + LastSeen: time.Now().UTC(), } return s.repo.Create(ctx, node) } @@ -199,7 +211,7 @@ func (s *NodeService) ListDirectory(ctx context.Context, nodeID uint, path strin } // Heartbeat updates the node status when an agent reports in. -func (s *NodeService) Heartbeat(ctx context.Context, token string, hostname string, ip string, agentVer string) error { +func (s *NodeService) Heartbeat(ctx context.Context, token string, hostname string, ip string, agentVer string, osName string, archName string) error { node, err := s.repo.FindByToken(ctx, token) if err != nil { return err @@ -211,12 +223,36 @@ func (s *NodeService) Heartbeat(ctx context.Context, token string, hostname stri node.Hostname = hostname node.IPAddress = ip node.AgentVer = agentVer - node.OS = runtime.GOOS - node.Arch = runtime.GOARCH + if strings.TrimSpace(osName) != "" { + node.OS = osName + } else { + node.OS = runtime.GOOS + } + if strings.TrimSpace(archName) != "" { + node.Arch = archName + } else { + node.Arch = runtime.GOARCH + } node.LastSeen = time.Now().UTC() return s.repo.Update(ctx, node) } +// Update 编辑节点名称。 +func (s *NodeService) Update(ctx context.Context, id uint, input NodeUpdateInput) (*NodeSummary, error) { + node, err := s.repo.FindByID(ctx, id) + if err != nil { + return nil, err + } + if node == nil { + return nil, apperror.New(http.StatusNotFound, "NODE_NOT_FOUND", "节点不存在", nil) + } + node.Name = strings.TrimSpace(input.Name) + if err := s.repo.Update(ctx, node); err != nil { + return nil, err + } + return s.Get(ctx, id) +} + // DirEntry represents a file or directory in a node's file system. type DirEntry struct { Name string `json:"name"` @@ -225,6 +261,22 @@ type DirEntry struct { Size int64 `json:"size"` } +// detectLocalIP 获取本机第一个非回环 IPv4 地址。 +func detectLocalIP() string { + addrs, err := net.InterfaceAddrs() + if err != nil { + return "" + } + for _, addr := range addrs { + if ipNet, ok := addr.(*net.IPNet); ok && !ipNet.IP.IsLoopback() { + if ipNet.IP.To4() != nil { + return ipNet.IP.String() + } + } + } + return "" +} + func generateToken() (string, error) { b := make([]byte, 32) if _, err := rand.Read(b); err != nil { diff --git a/server/internal/service/system_service.go b/server/internal/service/system_service.go index 36307ed..3880f55 100644 --- a/server/internal/service/system_service.go +++ b/server/internal/service/system_service.go @@ -5,8 +5,6 @@ import ( "encoding/json" "fmt" "net/http" - "os" - "os/exec" "path/filepath" "runtime" "strings" @@ -135,62 +133,3 @@ func (s *SystemService) GetInfo(_ context.Context) *SystemInfo { return info } -// UpdateApplyResult 描述自动更新执行结果。 -type UpdateApplyResult struct { - Success bool `json:"success"` - Message string `json:"message"` - Output string `json:"output,omitempty"` -} - -// IsDockerEnvironment 检测当前是否运行在 Docker 容器中。 -func (s *SystemService) IsDockerEnvironment() bool { - if _, err := os.Stat("/.dockerenv"); err == nil { - return true - } - return false -} - -// ApplyDockerUpdate 执行 Docker 自动更新:pull 新镜像 + recreate 容器。 -// 容器会在 docker compose up -d 后自动重启为新版本。 -func (s *SystemService) ApplyDockerUpdate(_ context.Context, targetVersion string) *UpdateApplyResult { - if !s.IsDockerEnvironment() { - return &UpdateApplyResult{Success: false, Message: "当前非 Docker 环境,请手动下载二进制更新"} - } - - image := "awuqing/backupx" - tag := strings.TrimSpace(targetVersion) - if tag == "" { - tag = "latest" - } - pullTarget := image + ":" + tag - - // Step 1: docker pull - pullCmd := exec.Command("docker", "pull", pullTarget) - pullOut, pullErr := pullCmd.CombinedOutput() - if pullErr != nil { - return &UpdateApplyResult{Success: false, Message: fmt.Sprintf("docker pull 失败: %v", pullErr), Output: string(pullOut)} - } - - // Step 2: docker compose up -d(后台执行,容器会自重启) - // 检测 compose 命令 - composeBin := "docker" - composeArgs := []string{"compose", "up", "-d"} - if _, err := exec.LookPath("docker-compose"); err == nil { - composeBin = "docker-compose" - composeArgs = []string{"up", "-d"} - } - - // 异步执行,给 API 响应留时间 - go func() { - time.Sleep(1 * time.Second) - cmd := exec.Command(composeBin, composeArgs...) - cmd.Dir = "/app" // Docker 容器中的工作目录 - _ = cmd.Run() - }() - - return &UpdateApplyResult{ - Success: true, - Message: fmt.Sprintf("已拉取 %s,容器即将自动重启到新版本", pullTarget), - Output: string(pullOut), - } -} diff --git a/server/internal/storage/rclone/provider.go b/server/internal/storage/rclone/provider.go index 92b8256..60f1257 100644 --- a/server/internal/storage/rclone/provider.go +++ b/server/internal/storage/rclone/provider.go @@ -4,6 +4,7 @@ import ( "context" "fmt" "io" + "sort" "strings" "time" @@ -124,6 +125,36 @@ func (p *Provider) About(ctx context.Context) (*storage.StorageUsageInfo, error) }, nil } +// RemoveEmptyDirs 递归删除 prefix 下的空目录,从最深层开始。 +// 非空目录删除会失败(安全忽略),仅清理真正的空目录。 +func (p *Provider) RemoveEmptyDirs(ctx context.Context, prefix string) error { + var dirs []string + err := walk.ListR(ctx, p.rfs, prefix, true, -1, walk.ListDirs, func(entries fs.DirEntries) error { + for _, entry := range entries { + if _, ok := entry.(fs.Directory); ok { + dirs = append(dirs, entry.Remote()) + } + } + return nil + }) + if err != nil { + // 列目录失败(比如目录不存在)静默返回 + return nil + } + // 按路径长度倒序(深目录优先删除),同长度保持稳定顺序 + sort.SliceStable(dirs, func(i, j int) bool { + return len(dirs[i]) > len(dirs[j]) + }) + for _, dir := range dirs { + _ = p.rfs.Rmdir(ctx, dir) + } + // 尝试清理 prefix 本身 + if prefix != "" { + _ = p.rfs.Rmdir(ctx, prefix) + } + return nil +} + // pathDir 返回 objectKey 的目录部分(正斜杠分隔)。 func pathDir(objectKey string) string { idx := strings.LastIndex(objectKey, "/") diff --git a/server/internal/storage/types.go b/server/internal/storage/types.go index 88f5e88..92672e7 100644 --- a/server/internal/storage/types.go +++ b/server/internal/storage/types.go @@ -145,3 +145,10 @@ type FTPConfig struct { UseTLS bool `json:"useTLS"` } +// StorageDirCleaner 是可选能力接口,支持清理空目录。 +// 主要用于本地磁盘等文件系统类存储,对象存储通常不需要。 +// 通过 type assertion 检测 provider 是否实现该接口。 +type StorageDirCleaner interface { + RemoveEmptyDirs(ctx context.Context, prefix string) error +} + diff --git a/web/src/components/common/DirectoryPicker.tsx b/web/src/components/common/DirectoryPicker.tsx index 3cf9db6..c24b15a 100644 --- a/web/src/components/common/DirectoryPicker.tsx +++ b/web/src/components/common/DirectoryPicker.tsx @@ -1,5 +1,5 @@ -import { Button, Input, Message, Modal, Space, Spin, Tree, Typography } from '@arco-design/web-react' -import { IconFolder, IconFile } from '@arco-design/web-react/icon' +import { Button, Input, Message, Modal, Space, Spin, Tree, Typography, Empty } from '@arco-design/web-react' +import { IconFolder, IconFile, IconFolderAdd } from '@arco-design/web-react/icon' import { useCallback, useState } from 'react' import { listNodeDirectory } from '../../services/nodes' import type { DirEntry } from '../../types/nodes' @@ -27,7 +27,7 @@ function entriesToTreeNodes(entries: DirEntry[], mode: 'directory' | 'file'): Tr .map((entry) => ({ key: entry.path, title: entry.name, - icon: entry.isDir ? : , + icon: entry.isDir ? : , isLeaf: !entry.isDir, })) } @@ -94,46 +94,83 @@ export function DirectoryPicker({ value, onChange, placeholder, mode = 'director setModalVisible(false) } + function handleInputKeyDown(e: React.KeyboardEvent) { + if (e.key === 'Enter') { + e.preventDefault() + const trimmed = value?.trim() + if (trimmed) { + onChange(trimmed) + } + } + } + // 没有 nodeId 时退化为普通输入框 if (nodeId === undefined) { - return + return } return ( <> - - - - + setModalVisible(false)} onOk={handleConfirm} - okText="选择" + okText="确认选择" cancelText="取消" - style={{ width: 560 }} + style={{ width: 640 }} okButtonProps={{ disabled: !selectedPath }} unmountOnExit > - {selectedPath && ( -
- + {/* 当前选中路径 */} +
+ + {selectedPath ? ( + {selectedPath} -
- )} + ) : ( + 请在下方目录树中选择路径 + )} +
+ + {/* 目录树 */} {loading ? ( - + ) : treeData.length === 0 ? ( - - 目录为空 - + ) : ( -
+
+ {opt.key}{opt.required ? ' *' : ''} + {opt.isPassword ? ( + updateConfig(opt.key, v)} /> + ) : ( + updateConfig(opt.key, v)} /> + )} + {opt.label && ( + {opt.label} + )} +
+ ) + } + + // 渲染动态字段(rclone 后端)— 必填优先,可选折叠 function renderDynamicFields() { + const requiredOptions = dynamicBackend?.options.filter((opt) => opt.required) ?? [] + const optionalOptions = dynamicBackend?.options.filter((opt) => !opt.required) ?? [] + return ( <>
@@ -147,19 +167,19 @@ export function StorageTargetFormDrawer({ updateConfig('root', v)} /> 远端根路径、桶名或挂载点,留空使用根目录
- {dynamicBackend && dynamicBackend.options.length > 0 && dynamicBackend.options.map((opt) => ( -
- {opt.key}{opt.required ? ' *' : ''} - {opt.isPassword ? ( - updateConfig(opt.key, v)} /> - ) : ( - updateConfig(opt.key, v)} /> - )} - {opt.label && ( - {opt.label} - )} -
- ))} + {requiredOptions.map(renderDynamicOption)} + {optionalOptions.length > 0 && ( + + 高级配置({optionalOptions.length} 个可选项)} + name="advanced" + > + + {optionalOptions.map(renderDynamicOption)} + + + + )} ) } diff --git a/web/src/pages/nodes/NodesPage.tsx b/web/src/pages/nodes/NodesPage.tsx index 75fe2d5..1fbe828 100644 --- a/web/src/pages/nodes/NodesPage.tsx +++ b/web/src/pages/nodes/NodesPage.tsx @@ -3,10 +3,10 @@ import { Table, Button, Space, Tag, Typography, PageHeader, Modal, Input, Message, Badge, Popconfirm, Card, Descriptions, Empty } from '@arco-design/web-react' import { - IconPlus, IconDelete, IconDesktop, IconCloudDownload + IconPlus, IconDelete, IconDesktop, IconCloudDownload, IconEdit } from '@arco-design/web-react/icon' import type { NodeSummary } from '../../types/nodes' -import { listNodes, createNode, deleteNode } from '../../services/nodes' +import { listNodes, createNode, deleteNode, updateNode } from '../../services/nodes' const { Title, Text } = Typography @@ -17,6 +17,11 @@ export default function NodesPage() { const [newNodeName, setNewNodeName] = useState('') const [newToken, setNewToken] = useState('') + // 编辑状态 + const [editVisible, setEditVisible] = useState(false) + const [editNode, setEditNode] = useState(null) + const [editName, setEditName] = useState('') + const fetchNodes = useCallback(async () => { setLoading(true) try { @@ -56,6 +61,21 @@ export default function NodesPage() { } } + const handleEdit = async () => { + if (!editNode || !editName.trim()) { + Message.warning('请输入节点名称') + return + } + try { + await updateNode(editNode.id, { name: editName.trim() }) + Message.success('节点更新成功') + setEditVisible(false) + fetchNodes() + } catch { + Message.error('更新节点失败') + } + } + const columns = [ { title: '节点名称', @@ -110,15 +130,22 @@ export default function NodesPage() { }, { title: '操作', - width: 80, - render: (_: unknown, record: NodeSummary) => { - if (record.isLocal) return - - return ( - handleDelete(record.id)}> -
) } diff --git a/web/src/pages/settings/SettingsPage.tsx b/web/src/pages/settings/SettingsPage.tsx index 0ae8665..244a8c6 100644 --- a/web/src/pages/settings/SettingsPage.tsx +++ b/web/src/pages/settings/SettingsPage.tsx @@ -1,6 +1,6 @@ import { Badge, Button, Card, Descriptions, Grid, Link, Message, PageHeader, Space, Tag, Typography } from '@arco-design/web-react' import { useEffect, useState } from 'react' -import { fetchSystemInfo, checkUpdate, applyUpdate, type SystemInfo, type UpdateCheckResult } from '../../services/system' +import { fetchSystemInfo, checkUpdate, type SystemInfo, type UpdateCheckResult } from '../../services/system' import { resolveErrorMessage } from '../../utils/error' import { formatDuration } from '../../utils/format' @@ -24,7 +24,6 @@ export function SettingsPage() { const [error, setError] = useState('') const [updateResult, setUpdateResult] = useState(null) const [checking, setChecking] = useState(false) - const [applying, setApplying] = useState(false) useEffect(() => { let active = true @@ -53,24 +52,6 @@ export function SettingsPage() { } } - async function handleApplyUpdate() { - if (!updateResult?.latestVersion) return - setApplying(true) - try { - const result = await applyUpdate(updateResult.latestVersion) - if (result.success) { - Message.success('更新已触发,容器即将自动重启...') - setTimeout(() => Message.info('请等待 10-30 秒后刷新页面'), 3000) - } else { - Message.warning(result.message) - } - } catch (e) { - Message.error(resolveErrorMessage(e, '触发更新失败')) - } finally { - setApplying(false) - } - } - return ( @@ -124,9 +105,6 @@ export function SettingsPage() { )} - {updateResult.downloadUrl && ( @@ -138,13 +116,6 @@ export function SettingsPage() { )} - {updateResult.dockerImage && ( - - - {`docker pull ${updateResult.dockerImage}:${updateResult.latestVersion} && docker compose up -d`} - - - )} ) : ( diff --git a/web/src/services/nodes.ts b/web/src/services/nodes.ts index 3e1df0b..018b16c 100644 --- a/web/src/services/nodes.ts +++ b/web/src/services/nodes.ts @@ -16,6 +16,11 @@ export async function createNode(name: string) { return unwrapApiEnvelope(response.data) } +export async function updateNode(id: number, data: { name: string }) { + const response = await http.put>(`/nodes/${id}`, data) + return unwrapApiEnvelope(response.data) +} + export async function deleteNode(id: number) { const response = await http.delete>(`/nodes/${id}`) return unwrapApiEnvelope(response.data) diff --git a/web/src/services/system.ts b/web/src/services/system.ts index c425cf9..c2eaa45 100644 --- a/web/src/services/system.ts +++ b/web/src/services/system.ts @@ -33,17 +33,6 @@ export async function checkUpdate() { return response.data.data } -export interface UpdateApplyResult { - success: boolean - message: string - output?: string -} - -export async function applyUpdate(version: string) { - const response = await http.post<{ code: string; message: string; data: UpdateApplyResult }>('/system/update-apply', { version }) - return response.data.data -} - export async function fetchSettings() { const response = await http.get<{ code: string; message: string; data: Record }>('/settings') return response.data.data