mirror of
https://github.com/Awuqing/BackupX.git
synced 2026-05-11 09:59:56 +08:00
feat: add community enhancements — password reset, audit logs, multi-source backup
Three community-requested features: 1. CLI password reset: `backupx reset-password --username admin --password xxx` Docker users can run via `docker exec`. No full app init needed. 2. Audit logging: async fire-and-forget audit trail for all key operations (login, CRUD on tasks/targets/records, settings changes). New UI page at /audit with category filter and pagination. 3. Multi-source path backup: file backup tasks now support multiple source directories packed into a single tar archive. Backward compatible with existing single sourcePath field.
This commit is contained in:
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -19,6 +19,7 @@ type TaskSpec struct {
|
||||
Name string
|
||||
Type string
|
||||
SourcePath string
|
||||
SourcePaths []string
|
||||
ExcludePatterns []string
|
||||
Database DatabaseSpec
|
||||
StorageTargetID uint
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
40
server/internal/http/audit_handler.go
Normal file
40
server/internal/http/audit_handler.go
Normal file
@@ -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)
|
||||
}
|
||||
29
server/internal/http/audit_helpers.go
Normal file
29
server/internal/http/audit_helpers.go
Normal file
@@ -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(),
|
||||
})
|
||||
}
|
||||
@@ -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})
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
30
server/internal/http/database_handler.go
Normal file
30
server/internal/http/database_handler.go
Normal file
@@ -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)
|
||||
}
|
||||
@@ -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))
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
21
server/internal/model/audit_log.go
Normal file
21
server/internal/model/audit_log.go
Normal file
@@ -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"
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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"`
|
||||
|
||||
56
server/internal/repository/audit_log_repository.go
Normal file
56
server/internal/repository/audit_log_repository.go
Normal file
@@ -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
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
68
server/internal/service/audit_service.go
Normal file
68
server/internal/service/audit_service.go
Normal file
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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: "",
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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" {
|
||||
|
||||
141
server/internal/service/database_discovery_service.go
Normal file
141
server/internal/service/database_discovery_service.go
Normal file
@@ -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
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user