├── .dockerignore ├── .gitignore ├── README.md ├── api ├── controller │ ├── auth_controller.go │ ├── cleanup_controller.go │ ├── config_controller.go │ ├── record_controller.go │ └── task_controller.go ├── middleware │ ├── auth_middleware.go │ └── middleware.go └── router │ └── router.go ├── config.yaml ├── config └── config.go ├── entity └── backup.go ├── go.mod ├── go.sum ├── main.go ├── model └── response.go ├── public ├── assets │ ├── css │ │ └── style.css │ ├── fonts │ │ ├── bootstrap-icons.woff │ │ └── bootstrap-icons.woff2 │ ├── img │ │ ├── logo.svg │ │ └── logo2.svg │ ├── js │ │ └── app.js │ └── libs │ │ ├── bootstrap-icons.min.css │ │ ├── bootstrap.bundle.min.js │ │ ├── bootstrap.min.css │ │ ├── cron-validator.min.js │ │ ├── sweetalert2.all.min.js │ │ └── sweetalert2.min.css ├── index.html └── login.html ├── repository ├── backup_record.go ├── backup_task.go ├── config_repository.go └── database.go └── service ├── backup ├── backup.go ├── backup_init.go ├── database_backup.go └── file_backup.go ├── cleanup └── cleanup_service.go ├── config ├── config_service.go └── webhook_service.go ├── scheduler └── scheduler.go └── storage ├── local_storage.go ├── s3_storage.go └── storage.go /.dockerignore: -------------------------------------------------------------------------------- 1 | # Git相关 2 | .git 3 | .gitignore 4 | .github 5 | 6 | # IDE相关 7 | .idea 8 | .vscode 9 | *.swp 10 | *.swo 11 | 12 | # 构建输出 13 | bin 14 | build 15 | dist 16 | *.exe 17 | *.exe~ 18 | *.dll 19 | *.so 20 | *.dylib 21 | 22 | # 依赖目录 23 | vendor 24 | 25 | # 测试文件 26 | *_test.go 27 | *.test 28 | 29 | # 临时文件和日志 30 | *.log 31 | *.tmp 32 | logs/ 33 | tmp/ 34 | 35 | # 备份文件(可以选择性地排除) 36 | backup/ 37 | 38 | # 数据文件(可以选择性地排除) 39 | data/ 40 | 41 | # Docker相关 42 | .dockerignore 43 | Dockerfile 44 | docker-compose.yml 45 | 46 | # 其他 47 | README.md 48 | LICENSE 49 | *.md -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .idea 2 | build 3 | backup.db 4 | test 5 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Backup-Go | 备份系统 2 | 3 |
4 | Go Version 5 | License: MIT 6 |
7 | 8 | 一个使用Go语言开发的灵活备份系统,支持数据库备份和文件备份,并提供Web界面进行管理。 9 | 10 | A flexible backup system developed with Go language, supporting database and file backups with a web-based management interface. 11 | 12 | ## ✨ 功能特点 | Features 13 | 14 | - 🗄️ 支持MySQL数据库备份 15 | - 📁 支持文件和目录备份 16 | - 💾 支持本地存储和S3协议存储 17 | - 🔌 可扩展的存储和备份类型 18 | - ⏱️ 基于Cron的任务调度 19 | - 🌐 美观的Web管理界面 20 | - 📊 备份历史记录和下载功能 21 | - 🧹 自动清理过期备份 22 | 23 | ## 🔧 系统要求 | Requirements 24 | 25 | - Go 1.21+ 26 | - MySQL 5.7+ 或 SQLite 27 | - mysqldump命令行工具 (用于数据库备份) 28 | 29 | ## 🚀 快速开始 | Quick Start 30 | 31 | ### 1. 克隆仓库 | Clone Repository 32 | 33 | ```bash 34 | git clone https://github.com/yourusername/backup-go.git 35 | cd backup-go 36 | ``` 37 | 38 | ### 2. 配置 | Configuration 39 | 40 | 创建或编辑`config.yaml`文件: 41 | 42 | ```yaml 43 | # 服务器配置 44 | server: 45 | port: 8080 46 | 47 | # 数据库配置 48 | database: 49 | type: sqlite # mysql或sqlite 50 | # 如果需要使用MySQL,请配置以下内容 51 | # host: 127.0.0.1 52 | # username: root 53 | # password: your-password 54 | # port: 3306 55 | # name: backup_go 56 | ``` 57 | 58 | > **注意**: 如果您需要使用S3协议存储,可以在Web界面中进行配置。 59 | 60 | ### 3. 构建和运行 | Build and Run 61 | 62 | ```bash 63 | # 下载依赖 64 | go mod tidy 65 | 66 | # 构建 67 | go build -o backup-go 68 | 69 | # 运行 70 | ./backup-go 71 | ``` 72 | 73 | ### 4. 访问Web界面 | Access Web UI 74 | 75 | 在浏览器中访问 http://localhost:8080 76 | 77 | ## 🐳 Docker 部署指南 | Docker Deployment Guide 78 | 79 | ### 使用预构建镜像 | Using Pre-built Images 80 | 81 | 我们提供了预构建的多架构 Docker 镜像,支持 `amd64`、`arm64` 等平台: 82 | 83 | ```bash 84 | # 拉取最新版本 85 | docker pull zzzgr/backup-go:1.0.0-amd64 86 | 87 | # 运行容器 88 | docker run -d --name backup-go -p 8080:8080 \ 89 | -v $(pwd):/app \ 90 | zzzgr/backup-go:1.0.0-amd64 91 | ``` 92 | 93 | ### 目录映射说明 | Volume Mapping 94 | 95 | - `/app/config.yaml`: 配置文件 96 | 97 | ## 📂 项目结构 | Project Structure 98 | 99 | ``` 100 | backup-go/ 101 | ├── api/ # API层,包含路由和控制器 102 | │ ├── controller/ # 控制器 103 | │ ├── middleware/ # 中间件 104 | │ └── router/ # 路由配置 105 | ├── config/ # 配置相关 106 | ├── entity/ # 数据实体模型 107 | ├── model/ # 数据访问层 108 | ├── public/ # 静态资源 109 | ├── repository/ # 数据仓库 110 | ├── service/ # 业务逻辑层 111 | │ ├── backup/ # 备份服务实现 112 | │ ├── cleanup/ # 清理服务 113 | │ ├── config/ # 配置服务 114 | │ ├── scheduler/ # 调度服务 115 | │ └── storage/ # 存储服务实现 116 | ├── test/ # 测试代码 117 | ├── config.yaml # 配置文件 118 | └── main.go # 程序入口 119 | ``` 120 | 121 | ## 🔍 使用指南 | Usage Guide 122 | 123 | ### 创建数据库备份任务 | Create Database Backup Task 124 | 125 | 1. 在Web界面点击"新建任务" 126 | 2. 选择"数据库备份"类型 127 | 3. 填写数据库连接信息 128 | 4. 配置调度计划(Cron表达式) 129 | 5. 选择存储方式 130 | 6. 保存任务 131 | 132 | ### 创建文件备份任务 | Create File Backup Task 133 | 134 | 1. 在Web界面点击"新建任务" 135 | 2. 选择"文件备份"类型 136 | 3. 填写要备份的文件/目录路径(每行一个) 137 | 4. 配置调度计划(Cron表达式) 138 | 5. 选择存储方式 139 | 6. 保存任务 140 | 141 | ### 手动执行任务 | Manual Execution 142 | 143 | 在任务列表中点击对应任务的"执行"按钮即可手动触发备份任务。 144 | 145 | ### 查看和下载备份 | View and Download Backups 146 | 147 | 在导航栏切换到"备份记录"页面,可以查看所有备份记录,对于成功的备份可以点击"下载"按钮下载备份文件。 148 | 149 | ## 🏗️ 架构设计 | Architecture Design 150 | 151 | 本系统采用模块化设计,易于扩展: 152 | 153 | - **存储服务接口**: 支持本地存储和S3协议存储,可以扩展更多存储方式 154 | - **备份服务接口**: 支持数据库备份和文件备份,可以扩展更多备份类型 155 | - **Cron调度器**: 基于robfig/cron库实现任务调度 156 | - **Web API**: 提供RESTful API接口 157 | - **前端界面**: 基于Bootstrap实现的现代化Web界面 158 | 159 | ## 🧪 最新特性 | Latest Features 160 | 161 | - **支持SQLite**: 除MySQL外,现在还支持SQLite作为系统数据库 162 | - **备份统计**: 添加了备份统计页面,展示备份趋势和使用情况 163 | - **多语言支持**: 界面支持中文和英文 164 | - **S3兼容存储**: 支持Amazon S3, MinIO, 阿里云OSS等S3兼容存储 165 | - **备份数据加密**: 支持对备份数据进行加密存储 166 | 167 | ## 🤝 贡献 | Contributing 168 | 169 | 我们欢迎各种形式的贡献,包括但不限于: 170 | 171 | - 提交 Issue 报告bug或提出新功能建议 172 | - 提交 Pull Request 改进代码 173 | - 改进文档 174 | - 分享使用经验 175 | 176 | 贡献前请查看我们的贡献指南。 177 | 178 | ## 部分截图 179 | ![1](https://y.gtimg.cn/music/photo_new/T053M0000005LLNj21t4aG.png) 180 | ![2](https://y.gtimg.cn/music/photo_new/T053M000001y0Vdg3xiAvF.png) 181 | ![3](https://y.gtimg.cn/music/photo_new/T053M0000022aBIa10t4rd.png) 182 | ![4](https://y.gtimg.cn/music/photo_new/T053M000003IexFM2kb2Dr.png) 183 | 184 | 185 | ## 📄 许可证 | License 186 | 187 | 本项目采用 [MIT 许可证](LICENSE) 进行许可。 188 | -------------------------------------------------------------------------------- /api/controller/auth_controller.go: -------------------------------------------------------------------------------- 1 | package controller 2 | 3 | import ( 4 | "backup-go/model" 5 | "backup-go/service/config" 6 | "encoding/json" 7 | "net/http" 8 | "strings" 9 | "sync" 10 | "time" 11 | 12 | "github.com/google/uuid" 13 | ) 14 | 15 | // 用于存储活跃token的内存映射表 16 | var ( 17 | activeTokens = make(map[string]time.Time) // token -> 过期时间 18 | tokenMutex = &sync.RWMutex{} 19 | ) 20 | 21 | // 令牌过期时间(24小时) 22 | const tokenExpiration = 24 * time.Hour 23 | 24 | // AuthController 身份验证控制器 25 | type AuthController struct { 26 | configService *config.ConfigService 27 | } 28 | 29 | // NewAuthController 创建控制器实例 30 | func NewAuthController() *AuthController { 31 | return &AuthController{ 32 | configService: config.NewConfigService(), 33 | } 34 | } 35 | 36 | // Login 用户登录 37 | // @Summary 用户登录 38 | // @Description 根据密码登录系统 39 | // @Tags 身份验证 40 | // @Accept json 41 | // @Produce json 42 | // @Param data body loginRequest true "登录信息" 43 | // @Success 200 {object} model.Response 44 | // @Router /api/auth/login [post] 45 | func (c *AuthController) Login(w http.ResponseWriter, r *http.Request) { 46 | // 解析请求体 47 | var req loginRequest 48 | if err := json.NewDecoder(r.Body).Decode(&req); err != nil { 49 | WriteJSONResponse(w, model.FailResponse("无效的请求数据")) 50 | return 51 | } 52 | 53 | // 获取系统密码配置 54 | systemPasswordConfig, err := c.configService.GetConfigByKey("system.password") 55 | if err != nil { 56 | // 未设置密码,默认允许登录 57 | token := generateToken() 58 | WriteJSONResponse(w, model.SuccessResponse(map[string]string{ 59 | "token": token, 60 | })) 61 | return 62 | } 63 | 64 | // 验证密码 65 | if req.Password != systemPasswordConfig.ConfigValue { 66 | WriteJSONResponse(w, model.FailResponse("密码错误")) 67 | return 68 | } 69 | 70 | // 生成token 71 | token := generateToken() 72 | 73 | // 返回成功响应 74 | WriteJSONResponse(w, model.SuccessResponse(map[string]string{ 75 | "token": token, 76 | })) 77 | } 78 | 79 | // CheckAuth 验证用户身份 80 | // @Summary 验证用户身份 81 | // @Description 验证用户是否已登录 82 | // @Tags 身份验证 83 | // @Produce json 84 | // @Success 200 {object} model.Response 85 | // @Router /api/auth/check [get] 86 | func (c *AuthController) CheckAuth(w http.ResponseWriter, r *http.Request) { 87 | // 获取系统密码配置 88 | systemPasswordConfig, err := c.configService.GetConfigByKey("system.password") 89 | if err != nil { 90 | // 未设置密码,不需要验证 91 | WriteJSONResponse(w, model.SuccessResponse(nil)) 92 | return 93 | } 94 | 95 | // 检查密码是否为空 96 | if systemPasswordConfig.ConfigValue == "" { 97 | // 密码为空,不需要验证 98 | WriteJSONResponse(w, model.SuccessResponse(nil)) 99 | return 100 | } 101 | 102 | // 获取token 103 | token := r.Header.Get("Authorization") 104 | if token == "" { 105 | WriteJSONResponse(w, model.ErrorWithCode(401, "未授权")) 106 | return 107 | } 108 | 109 | // 验证token格式 110 | if !strings.HasPrefix(token, "Bearer ") { 111 | WriteJSONResponse(w, model.ErrorWithCode(401, "无效的凭证格式")) 112 | return 113 | } 114 | 115 | // 提取token值(去除Bearer前缀) 116 | tokenValue := strings.TrimPrefix(token, "Bearer ") 117 | 118 | // 验证token是否有效 119 | if !IsValidToken(tokenValue) { 120 | WriteJSONResponse(w, model.ErrorWithCode(401, "无效的凭证或已过期")) 121 | return 122 | } 123 | 124 | // 验证通过 125 | WriteJSONResponse(w, model.SuccessResponse(nil)) 126 | } 127 | 128 | // loginRequest 登录请求结构 129 | type loginRequest struct { 130 | Password string `json:"password"` 131 | } 132 | 133 | // generateToken 生成UUID作为token 134 | func generateToken() string { 135 | // 生成UUID作为token 136 | token := uuid.New().String() 137 | 138 | // 保存到活跃token映射表中 139 | tokenMutex.Lock() 140 | defer tokenMutex.Unlock() 141 | 142 | // 设置过期时间 143 | activeTokens[token] = time.Now().Add(tokenExpiration) 144 | 145 | // 清理过期token 146 | cleanExpiredTokens() 147 | 148 | return token 149 | } 150 | 151 | // isValidToken 检查token是否有效 152 | func IsValidToken(token string) bool { 153 | tokenMutex.RLock() 154 | defer tokenMutex.RUnlock() 155 | 156 | expireTime, exists := activeTokens[token] 157 | return exists && time.Now().Before(expireTime) 158 | } 159 | 160 | // cleanExpiredTokens 清理过期的token 161 | func cleanExpiredTokens() { 162 | now := time.Now() 163 | for token, expireTime := range activeTokens { 164 | if now.After(expireTime) { 165 | delete(activeTokens, token) 166 | } 167 | } 168 | } 169 | 170 | // WriteJSONResponse 写入JSON响应 171 | func WriteJSONResponse(w http.ResponseWriter, data interface{}) { 172 | w.Header().Set("Content-Type", "application/json") 173 | json.NewEncoder(w).Encode(data) 174 | } 175 | -------------------------------------------------------------------------------- /api/controller/cleanup_controller.go: -------------------------------------------------------------------------------- 1 | package controller 2 | 3 | import ( 4 | "backup-go/model" 5 | "backup-go/service/cleanup" 6 | "log" 7 | "net/http" 8 | "strconv" 9 | ) 10 | 11 | // CleanupController 清理控制器 12 | type CleanupController struct { 13 | cleanupService *cleanup.CleanupService 14 | } 15 | 16 | // NewCleanupController 创建清理控制器 17 | func NewCleanupController() *CleanupController { 18 | return &CleanupController{ 19 | cleanupService: cleanup.GetCleanupService(), 20 | } 21 | } 22 | 23 | // ExecuteCleanup 执行清理 24 | func (c *CleanupController) ExecuteCleanup(w http.ResponseWriter, r *http.Request) { 25 | // 执行清理并获取结果 26 | result := c.cleanupService.ExecuteAndGetResult() 27 | 28 | // 发送webhook通知(手动执行) 29 | if err := c.cleanupService.GetWebhookService().SendCleanupNotification(result.Success, result.Failed, result.Skipped, false, result.ErrorMessages); err != nil { 30 | // 仅记录日志,不影响正常响应 31 | log.Printf("发送清理通知失败: %v", err) 32 | } 33 | 34 | // 构建响应消息 35 | message := "" 36 | if len(result.ErrorMessages) > 0 { 37 | message = "清理任务完成,但存在以下问题: " + result.ErrorMessages[0] 38 | if len(result.ErrorMessages) > 1 { 39 | message += " (还有" + strconv.Itoa(len(result.ErrorMessages)-1) + "个其他错误)" 40 | } 41 | } else { 42 | message = "清理任务已完成" 43 | } 44 | 45 | // 返回响应 46 | data := map[string]interface{}{ 47 | "success": result.Success, 48 | "failed": result.Failed, 49 | "skipped": result.Skipped, 50 | "errors": result.ErrorMessages, 51 | } 52 | 53 | c.writeJSON(w, model.SuccessWithMsg(data, message)) 54 | } 55 | 56 | // writeJSON 输出JSON 57 | func (c *CleanupController) writeJSON(w http.ResponseWriter, data interface{}) { 58 | w.Header().Set("Content-Type", "application/json") 59 | model.WriteJSON(w, data) 60 | } 61 | -------------------------------------------------------------------------------- /api/controller/config_controller.go: -------------------------------------------------------------------------------- 1 | package controller 2 | 3 | import ( 4 | "backup-go/entity" 5 | "backup-go/model" 6 | "backup-go/service/config" 7 | "encoding/json" 8 | "net/http" 9 | "strconv" 10 | ) 11 | 12 | // ConfigController 配置控制器 13 | type ConfigController struct { 14 | configService *config.ConfigService 15 | webhookService *config.WebhookService 16 | } 17 | 18 | // NewConfigController 创建控制器实例 19 | func NewConfigController() *ConfigController { 20 | return &ConfigController{ 21 | configService: config.NewConfigService(), 22 | webhookService: config.NewWebhookService(), 23 | } 24 | } 25 | 26 | // GetConfigList 获取配置列表 27 | // @Summary 获取配置列表 28 | // @Description 获取所有配置项 29 | // @Tags 配置管理 30 | // @Produce json 31 | // @Success 200 {object} model.Response 32 | // @Router /api/configs [get] 33 | func (c *ConfigController) GetConfigList(w http.ResponseWriter, r *http.Request) { 34 | configs, err := c.configService.GetAllConfigs() 35 | if err != nil { 36 | WriteJSONResponse(w, model.FailResponse(err.Error())) 37 | return 38 | } 39 | WriteJSONResponse(w, model.SuccessResponse(configs)) 40 | } 41 | 42 | // GetConfig 获取单个配置 43 | // @Summary 获取单个配置 44 | // @Description 根据ID获取配置详情 45 | // @Tags 配置管理 46 | // @Produce json 47 | // @Param id query int true "配置ID" 48 | // @Success 200 {object} model.Response 49 | // @Router /api/configs/get [get] 50 | func (c *ConfigController) GetConfig(w http.ResponseWriter, r *http.Request) { 51 | idStr := r.URL.Query().Get("id") 52 | id, err := strconv.ParseUint(idStr, 10, 64) 53 | if err != nil { 54 | WriteJSONResponse(w, model.FailResponse("无效的ID")) 55 | return 56 | } 57 | 58 | config, err := c.configService.GetConfig(uint(id)) 59 | if err != nil { 60 | WriteJSONResponse(w, model.FailResponse(err.Error())) 61 | return 62 | } 63 | 64 | WriteJSONResponse(w, model.SuccessResponse(config)) 65 | } 66 | 67 | // GetConfigByKey 根据键获取配置 68 | // @Summary 根据键获取配置 69 | // @Description 根据键名获取配置详情 70 | // @Tags 配置管理 71 | // @Produce json 72 | // @Param key query string true "配置键" 73 | // @Success 200 {object} model.Response 74 | // @Router /api/configs/getByKey [get] 75 | func (c *ConfigController) GetConfigByKey(w http.ResponseWriter, r *http.Request) { 76 | key := r.URL.Query().Get("key") 77 | if key == "" { 78 | WriteJSONResponse(w, model.FailResponse("配置键不能为空")) 79 | return 80 | } 81 | 82 | config, err := c.configService.GetConfigByKey(key) 83 | if err != nil { 84 | WriteJSONResponse(w, model.FailResponse(err.Error())) 85 | return 86 | } 87 | 88 | WriteJSONResponse(w, model.SuccessResponse(config)) 89 | } 90 | 91 | // CreateConfig 创建配置 92 | // @Summary 创建配置 93 | // @Description 创建新的配置项 94 | // @Tags 配置管理 95 | // @Accept json 96 | // @Produce json 97 | // @Param config body entity.SystemConfig true "配置信息" 98 | // @Success 200 {object} model.Response 99 | // @Router /api/configs [post] 100 | func (c *ConfigController) CreateConfig(w http.ResponseWriter, r *http.Request) { 101 | var config entity.SystemConfig 102 | if err := json.NewDecoder(r.Body).Decode(&config); err != nil { 103 | WriteJSONResponse(w, model.FailResponse("无效的请求数据: "+err.Error())) 104 | return 105 | } 106 | 107 | // 验证必填字段 108 | if config.ConfigKey == "" { 109 | WriteJSONResponse(w, model.FailResponse("配置键不能为空")) 110 | return 111 | } 112 | 113 | // 检查键是否已存在 114 | existingConfig, _ := c.configService.GetConfigByKey(config.ConfigKey) 115 | if existingConfig != nil { 116 | WriteJSONResponse(w, model.FailResponse("配置键已存在")) 117 | return 118 | } 119 | 120 | // 创建配置 121 | if err := c.configService.CreateConfig(&config); err != nil { 122 | WriteJSONResponse(w, model.FailResponse("创建配置失败: "+err.Error())) 123 | return 124 | } 125 | 126 | WriteJSONResponse(w, model.SuccessResponse(config)) 127 | } 128 | 129 | // UpdateConfig 更新配置 130 | // @Summary 更新配置 131 | // @Description 更新现有配置项 132 | // @Tags 配置管理 133 | // @Accept json 134 | // @Produce json 135 | // @Param id query int true "配置ID" 136 | // @Param config body entity.SystemConfig true "配置信息" 137 | // @Success 200 {object} model.Response 138 | // @Router /api/configs/update [post] 139 | func (c *ConfigController) UpdateConfig(w http.ResponseWriter, r *http.Request) { 140 | idStr := r.URL.Query().Get("id") 141 | id, err := strconv.ParseUint(idStr, 10, 64) 142 | if err != nil { 143 | WriteJSONResponse(w, model.FailResponse("无效的ID")) 144 | return 145 | } 146 | 147 | // 获取现有配置 148 | existingConfig, err := c.configService.GetConfig(uint(id)) 149 | if err != nil { 150 | WriteJSONResponse(w, model.FailResponse("配置不存在")) 151 | return 152 | } 153 | 154 | // 解析请求数据 155 | var updateData entity.SystemConfig 156 | if err := json.NewDecoder(r.Body).Decode(&updateData); err != nil { 157 | WriteJSONResponse(w, model.FailResponse("无效的请求数据: "+err.Error())) 158 | return 159 | } 160 | 161 | // 验证必填字段 162 | if updateData.ConfigKey == "" { 163 | WriteJSONResponse(w, model.FailResponse("配置键不能为空")) 164 | return 165 | } 166 | 167 | // 如果修改了键名,检查新键名是否已存在 168 | if updateData.ConfigKey != existingConfig.ConfigKey { 169 | checkConfig, _ := c.configService.GetConfigByKey(updateData.ConfigKey) 170 | if checkConfig != nil { 171 | WriteJSONResponse(w, model.FailResponse("配置键已存在")) 172 | return 173 | } 174 | } 175 | 176 | // 只更新需要的字段,保留其他原有信息 177 | existingConfig.ConfigKey = updateData.ConfigKey 178 | existingConfig.ConfigValue = updateData.ConfigValue 179 | existingConfig.Description = updateData.Description 180 | 181 | // 更新配置 182 | if err := c.configService.UpdateConfig(existingConfig); err != nil { 183 | WriteJSONResponse(w, model.FailResponse("更新配置失败: "+err.Error())) 184 | return 185 | } 186 | 187 | WriteJSONResponse(w, model.SuccessResponse(existingConfig)) 188 | } 189 | 190 | // DeleteConfig 删除配置 191 | // @Summary 删除配置 192 | // @Description 删除配置项 193 | // @Tags 配置管理 194 | // @Produce json 195 | // @Param id query int true "配置ID" 196 | // @Success 200 {object} model.Response 197 | // @Router /api/configs/delete [post] 198 | func (c *ConfigController) DeleteConfig(w http.ResponseWriter, r *http.Request) { 199 | idStr := r.URL.Query().Get("id") 200 | id, err := strconv.ParseUint(idStr, 10, 64) 201 | if err != nil { 202 | WriteJSONResponse(w, model.FailResponse("无效的ID")) 203 | return 204 | } 205 | 206 | // 检查是否存在 207 | _, err = c.configService.GetConfig(uint(id)) 208 | if err != nil { 209 | WriteJSONResponse(w, model.FailResponse("配置不存在")) 210 | return 211 | } 212 | 213 | // 删除配置 214 | if err := c.configService.DeleteConfig(uint(id)); err != nil { 215 | WriteJSONResponse(w, model.FailResponse("删除配置失败: "+err.Error())) 216 | return 217 | } 218 | 219 | WriteJSONResponse(w, model.SuccessResponse(nil)) 220 | } 221 | 222 | // TestWebhook 测试Webhook 223 | // @Summary 测试Webhook 224 | // @Description 测试Webhook配置是否正确 225 | // @Tags 配置管理 226 | // @Produce json 227 | // @Success 200 {object} model.Response 228 | // @Router /api/configs/testWebhook [post] 229 | func (c *ConfigController) TestWebhook(w http.ResponseWriter, r *http.Request) { 230 | // 解析请求体中的配置 231 | var tempConfig struct { 232 | URL string `json:"url"` 233 | Headers string `json:"headers"` 234 | Body string `json:"body"` 235 | } 236 | 237 | if err := json.NewDecoder(r.Body).Decode(&tempConfig); err != nil { 238 | WriteJSONResponse(w, model.FailResponse("解析请求参数失败: "+err.Error())) 239 | return 240 | } 241 | 242 | // 验证URL是否存在 243 | if tempConfig.URL == "" { 244 | WriteJSONResponse(w, model.FailResponse("URL不能为空")) 245 | return 246 | } 247 | 248 | // 使用表单配置进行测试 249 | err := c.webhookService.TestWebhookWithConfig(tempConfig.URL, tempConfig.Headers, tempConfig.Body) 250 | if err != nil { 251 | WriteJSONResponse(w, model.FailResponse("测试Webhook失败: "+err.Error())) 252 | return 253 | } 254 | 255 | WriteJSONResponse(w, model.SuccessResponse(nil)) 256 | } 257 | 258 | // GetSiteInfo 获取站点信息 259 | // @Summary 获取站点信息 260 | // @Description 获取站点名称和版本号信息,此接口可匿名访问 261 | // @Tags 系统信息 262 | // @Produce json 263 | // @Success 200 {object} model.Response 264 | // @Router /api/info [get] 265 | func (c *ConfigController) GetSiteInfo(w http.ResponseWriter, r *http.Request) { 266 | // 设置CORS头,允许跨域访问 267 | w.Header().Set("Access-Control-Allow-Origin", "*") 268 | w.Header().Set("Access-Control-Allow-Methods", "GET") 269 | w.Header().Set("Access-Control-Allow-Headers", "Content-Type") 270 | 271 | // 获取站点名称 272 | siteName, err := c.configService.GetConfigValueByKey("system.siteName") 273 | if err != nil || siteName == "" { 274 | siteName = "备份系统" // 默认站点名称 275 | } 276 | 277 | // 硬编码版本号 278 | version := "v1.0.0" // 系统版本号,硬编码 279 | 280 | // 构建响应数据 281 | data := map[string]string{ 282 | "siteName": siteName, 283 | "version": version, 284 | } 285 | 286 | WriteJSONResponse(w, model.SuccessResponse(data)) 287 | } 288 | 289 | // 通用JSON响应写入函数 290 | -------------------------------------------------------------------------------- /api/controller/record_controller.go: -------------------------------------------------------------------------------- 1 | package controller 2 | 3 | import ( 4 | "backup-go/entity" 5 | "backup-go/model" 6 | "backup-go/repository" 7 | configService "backup-go/service/config" 8 | "backup-go/service/storage" 9 | "encoding/json" 10 | "fmt" 11 | "io" 12 | "log" 13 | "net/http" 14 | "path/filepath" 15 | "strconv" 16 | "strings" 17 | ) 18 | 19 | // RecordController 备份记录控制器 20 | type RecordController struct { 21 | recordRepo *repository.BackupRecordRepository 22 | taskRepo *repository.BackupTaskRepository 23 | } 24 | 25 | // NewRecordController 创建备份记录控制器 26 | func NewRecordController() *RecordController { 27 | return &RecordController{ 28 | recordRepo: repository.NewBackupRecordRepository(), 29 | taskRepo: repository.NewBackupTaskRepository(), 30 | } 31 | } 32 | 33 | // GetRecord 获取备份记录 34 | func (c *RecordController) GetRecord(w http.ResponseWriter, r *http.Request) { 35 | id, err := strconv.ParseInt(r.URL.Query().Get("id"), 10, 64) 36 | if err != nil { 37 | c.writeJSON(w, model.Error(400, "Invalid record ID")) 38 | return 39 | } 40 | 41 | record, err := c.recordRepo.FindByID(id) 42 | if err != nil { 43 | c.writeJSON(w, model.Error(500, "Failed to find record: "+err.Error())) 44 | return 45 | } 46 | if record == nil { 47 | c.writeJSON(w, model.Error(404, "Record not found")) 48 | return 49 | } 50 | 51 | // 添加任务名称 52 | task, err := c.taskRepo.FindByID(record.TaskID) 53 | if err == nil && task != nil { 54 | record.TaskName = task.Name 55 | } else { 56 | record.TaskName = "未知任务" 57 | } 58 | 59 | c.writeJSON(w, model.Success(record)) 60 | } 61 | 62 | // GetAllRecords 获取所有备份记录 63 | func (c *RecordController) GetAllRecords(w http.ResponseWriter, r *http.Request) { 64 | page, err := strconv.Atoi(r.URL.Query().Get("page")) 65 | if err != nil || page < 1 { 66 | page = 1 67 | } 68 | 69 | pageSize, err := strconv.Atoi(r.URL.Query().Get("pageSize")) 70 | if err != nil || pageSize < 1 { 71 | pageSize = 10 72 | } 73 | 74 | records, err := c.recordRepo.FindAll(page, pageSize) 75 | if err != nil { 76 | c.writeJSON(w, model.Error(500, "Failed to find records: "+err.Error())) 77 | return 78 | } 79 | 80 | count, err := c.recordRepo.CountAll() 81 | if err != nil { 82 | c.writeJSON(w, model.Error(500, "Failed to count records: "+err.Error())) 83 | return 84 | } 85 | 86 | // 为每条记录添加任务名称 87 | for _, record := range records { 88 | // 根据TaskID查询任务名称 89 | task, err := c.taskRepo.FindByID(record.TaskID) 90 | if err == nil && task != nil { 91 | // 将任务名称添加到记录中 92 | record.TaskName = task.Name 93 | } else { 94 | // 如果查询失败,显示未知任务 95 | record.TaskName = "未知任务" 96 | } 97 | } 98 | 99 | result := map[string]interface{}{ 100 | "records": records, 101 | "total": count, 102 | "page": page, 103 | "pageSize": pageSize, 104 | } 105 | 106 | c.writeJSON(w, model.Success(result)) 107 | } 108 | 109 | // GetTaskRecords 获取任务的备份记录 110 | func (c *RecordController) GetTaskRecords(w http.ResponseWriter, r *http.Request) { 111 | taskID, err := strconv.ParseInt(r.URL.Query().Get("taskId"), 10, 64) 112 | if err != nil { 113 | c.writeJSON(w, model.Error(400, "Invalid task ID")) 114 | return 115 | } 116 | 117 | // 支持分页参数 118 | page, err := strconv.Atoi(r.URL.Query().Get("page")) 119 | if err != nil || page < 1 { 120 | page = 1 121 | } 122 | 123 | pageSize, err := strconv.Atoi(r.URL.Query().Get("pageSize")) 124 | if err != nil || pageSize < 1 { 125 | pageSize = 10 126 | } 127 | 128 | // 获取任务的总记录数 129 | count, err := c.recordRepo.CountByTaskID(taskID) 130 | if err != nil { 131 | c.writeJSON(w, model.Error(500, "Failed to count records: "+err.Error())) 132 | return 133 | } 134 | 135 | // 获取带分页的记录 136 | records, err := c.recordRepo.FindByTaskIDPaginated(taskID, page, pageSize) 137 | if err != nil { 138 | c.writeJSON(w, model.Error(500, "Failed to find records: "+err.Error())) 139 | return 140 | } 141 | 142 | // 获取任务信息以添加任务名称 143 | task, err := c.taskRepo.FindByID(taskID) 144 | if err == nil && task != nil { 145 | // 为所有记录添加任务名称 146 | for _, record := range records { 147 | record.TaskName = task.Name 148 | } 149 | } else { 150 | // 如果查询任务失败,使用"未知任务"作为任务名称 151 | for _, record := range records { 152 | record.TaskName = "未知任务" 153 | } 154 | } 155 | 156 | // 构建分页结果 157 | result := map[string]interface{}{ 158 | "records": records, 159 | "total": count, 160 | "page": page, 161 | "pageSize": pageSize, 162 | } 163 | 164 | c.writeJSON(w, model.Success(result)) 165 | } 166 | 167 | // DownloadBackup 下载备份文件 168 | func (c *RecordController) DownloadBackup(w http.ResponseWriter, r *http.Request) { 169 | // 检查是否启用密码保护 170 | cs := configService.NewConfigService() 171 | passwordConfig, err := cs.GetConfigByKey("system.password") 172 | 173 | // 如果设置了密码,需要验证token 174 | if err == nil && passwordConfig.ConfigValue != "" { 175 | // 获取token,可以从多个位置获取 176 | var token string 177 | 178 | // 1. 从Authorization头获取 179 | authHeader := r.Header.Get("Authorization") 180 | if strings.HasPrefix(authHeader, "Bearer ") { 181 | token = strings.TrimPrefix(authHeader, "Bearer ") 182 | } 183 | 184 | // 2. 从URL查询参数获取 185 | if token == "" { 186 | token = r.URL.Query().Get("token") 187 | } 188 | 189 | // 3. 从POST表单获取 190 | if token == "" { 191 | err := r.ParseForm() 192 | if err == nil { 193 | token = r.FormValue("token") 194 | } 195 | } 196 | 197 | // 验证token 198 | if token == "" || !IsValidToken(token) { 199 | c.writeJSON(w, model.Error(401, "未授权访问,请先登录")) 200 | return 201 | } 202 | } 203 | 204 | // 直接通过文件路径下载 205 | path := r.URL.Query().Get("path") 206 | if path != "" { 207 | // 检查路径安全性,避免任意文件访问 208 | if strings.Contains(path, "..") { 209 | c.writeJSON(w, model.Error(403, "Invalid file path")) 210 | return 211 | } 212 | 213 | // 获取存储服务 214 | storageType := entity.LocalStorage // 默认本地存储 215 | 216 | // 根据路径前缀判断存储类型 217 | if strings.HasPrefix(path, "s3://") { 218 | storageType = entity.S3Storage 219 | } 220 | 221 | // 获取对应的存储服务 222 | storageService, err := storage.NewStorageService(storageType) 223 | if err != nil { 224 | c.writeJSON(w, model.Error(500, "Failed to create storage service: "+err.Error())) 225 | return 226 | } 227 | 228 | // 获取文件 229 | file, err := storageService.Get(path) 230 | if err != nil { 231 | c.writeJSON(w, model.Error(500, "Failed to get backup file: "+err.Error())) 232 | return 233 | } 234 | defer file.Close() 235 | 236 | // 从文件路径中提取文件名 237 | filename := filepath.Base(path) 238 | 239 | // 设置响应头 240 | w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=%s", strconv.Quote(filename))) 241 | w.Header().Set("Content-Type", "application/octet-stream") 242 | 243 | // 发送文件内容 244 | _, err = io.Copy(w, file) 245 | if err != nil { 246 | c.writeJSON(w, model.Error(500, "Failed to send file: "+err.Error())) 247 | return 248 | } 249 | 250 | return 251 | } 252 | 253 | // 通过记录ID下载 254 | id, err := strconv.ParseInt(r.URL.Query().Get("id"), 10, 64) 255 | if err != nil { 256 | c.writeJSON(w, model.Error(400, "Invalid record ID")) 257 | return 258 | } 259 | 260 | // 获取备份记录 261 | record, err := c.recordRepo.FindByID(id) 262 | if err != nil { 263 | c.writeJSON(w, model.Error(500, "Failed to find record: "+err.Error())) 264 | return 265 | } 266 | if record == nil { 267 | c.writeJSON(w, model.Error(404, "Record not found")) 268 | return 269 | } 270 | 271 | // 获取任务信息 272 | task, err := c.taskRepo.FindByID(record.TaskID) 273 | if err != nil { 274 | c.writeJSON(w, model.Error(500, "Failed to find task: "+err.Error())) 275 | return 276 | } 277 | if task == nil { 278 | c.writeJSON(w, model.Error(404, "Task not found")) 279 | return 280 | } 281 | 282 | // 获取存储服务 283 | // 优先使用记录中存储的存储类型 284 | storageType := record.StorageType 285 | 286 | // 如果记录中没有存储类型(旧数据兼容处理),使用系统配置 287 | if storageType == "" { 288 | // 从系统配置表读取存储类型 289 | cs := configService.NewConfigService() 290 | storageTypeStr, err := cs.GetConfigValue("storage.type") 291 | if err == nil && storageTypeStr != "" { 292 | storageType = entity.StorageType(storageTypeStr) 293 | } else { 294 | // 配置表中也没有,尝试从文件路径判断 295 | filePath := record.FilePath 296 | if strings.HasPrefix(filePath, "s3://") || strings.HasPrefix(filePath, "backups/") { 297 | // S3存储或特定格式 298 | storageType = entity.S3Storage 299 | } else if filepath.IsAbs(filePath) || strings.HasPrefix(filePath, "./") || strings.HasPrefix(filePath, "../") { 300 | // 绝对路径或相对路径,视为本地存储 301 | storageType = entity.LocalStorage 302 | } 303 | } 304 | } 305 | 306 | // 获取对应的存储服务 307 | storageService, err := storage.NewStorageService(storageType) 308 | if err != nil { 309 | c.writeJSON(w, model.Error(500, "Failed to create storage service: "+err.Error())) 310 | return 311 | } 312 | 313 | // 获取文件 314 | file, err := storageService.Get(record.FilePath) 315 | if err != nil { 316 | c.writeJSON(w, model.Error(500, "Failed to get backup file: "+err.Error())) 317 | return 318 | } 319 | defer file.Close() 320 | 321 | // 从文件路径中提取文件名 322 | filename := filepath.Base(record.FilePath) 323 | 324 | // 设置响应头 325 | w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=%s", strconv.Quote(filename))) 326 | w.Header().Set("Content-Type", "application/octet-stream") 327 | 328 | // 发送文件内容 329 | _, err = io.Copy(w, file) 330 | if err != nil { 331 | c.writeJSON(w, model.Error(500, "Failed to send file: "+err.Error())) 332 | return 333 | } 334 | } 335 | 336 | // DeleteRecord 删除备份记录 337 | func (c *RecordController) DeleteRecord(w http.ResponseWriter, r *http.Request) { 338 | id, err := strconv.ParseInt(r.URL.Query().Get("id"), 10, 64) 339 | if err != nil { 340 | c.writeJSON(w, model.Error(400, "无效的记录ID")) 341 | return 342 | } 343 | 344 | // 获取备份记录 345 | record, err := c.recordRepo.FindByID(id) 346 | if err != nil { 347 | c.writeJSON(w, model.Error(500, "查找记录失败: "+err.Error())) 348 | return 349 | } 350 | if record == nil { 351 | c.writeJSON(w, model.Error(404, "记录不存在")) 352 | return 353 | } 354 | 355 | // 如果有备份文件路径,尝试删除文件 356 | if record.FilePath != "" { 357 | // 获取存储服务 358 | // 优先使用记录中存储的存储类型 359 | storageType := record.StorageType 360 | 361 | // 如果记录中没有存储类型(旧数据兼容处理),尝试从文件路径判断 362 | if storageType == "" { 363 | filePath := record.FilePath 364 | if strings.HasPrefix(filePath, "s3://") { 365 | storageType = entity.S3Storage 366 | } else { 367 | storageType = entity.LocalStorage 368 | } 369 | } 370 | 371 | // 获取对应的存储服务 372 | storageService, err := storage.NewStorageService(storageType) 373 | if err != nil { 374 | c.writeJSON(w, model.Error(500, "创建存储服务失败: "+err.Error())) 375 | return 376 | } 377 | 378 | // 删除文件 379 | err = storageService.Delete(record.FilePath) 380 | if err != nil { 381 | // 仅记录日志,不中断流程 382 | log.Printf("删除文件失败: %s, 错误: %v", record.FilePath, err) 383 | } 384 | } 385 | 386 | // 删除数据库记录 387 | err = c.recordRepo.Delete(id) 388 | if err != nil { 389 | c.writeJSON(w, model.Error(500, "删除记录失败: "+err.Error())) 390 | return 391 | } 392 | 393 | c.writeJSON(w, model.Success(nil)) 394 | } 395 | 396 | // 写入JSON响应 397 | func (c *RecordController) writeJSON(w http.ResponseWriter, data interface{}) { 398 | w.Header().Set("Content-Type", "application/json") 399 | json.NewEncoder(w).Encode(data) 400 | } 401 | -------------------------------------------------------------------------------- /api/controller/task_controller.go: -------------------------------------------------------------------------------- 1 | package controller 2 | 3 | import ( 4 | "backup-go/entity" 5 | "backup-go/model" 6 | "backup-go/repository" 7 | "backup-go/service/scheduler" 8 | "encoding/json" 9 | "log" 10 | "net/http" 11 | "strconv" 12 | "time" 13 | ) 14 | 15 | // TaskController 任务控制器 16 | type TaskController struct { 17 | taskRepo *repository.BackupTaskRepository 18 | recordRepo *repository.BackupRecordRepository 19 | scheduler *scheduler.BackupScheduler 20 | } 21 | 22 | // NewTaskController 创建任务控制器 23 | func NewTaskController() *TaskController { 24 | return &TaskController{ 25 | taskRepo: repository.NewBackupTaskRepository(), 26 | recordRepo: repository.NewBackupRecordRepository(), 27 | scheduler: scheduler.GetScheduler(), 28 | } 29 | } 30 | 31 | // CreateTask 创建任务 32 | func (c *TaskController) CreateTask(w http.ResponseWriter, r *http.Request) { 33 | var task entity.BackupTask 34 | if err := json.NewDecoder(r.Body).Decode(&task); err != nil { 35 | c.writeJSON(w, model.Error(400, "Invalid request: "+err.Error())) 36 | return 37 | } 38 | 39 | if err := c.taskRepo.Create(&task); err != nil { 40 | c.writeJSON(w, model.Error(500, "Failed to create task: "+err.Error())) 41 | return 42 | } 43 | 44 | // 添加到调度器 45 | if task.Enabled { 46 | if err := c.scheduler.AddTask(&task); err != nil { 47 | c.writeJSON(w, model.Error(500, "Failed to schedule task: "+err.Error())) 48 | return 49 | } 50 | } 51 | 52 | c.writeJSON(w, model.Success(task)) 53 | } 54 | 55 | // UpdateTask 更新任务 56 | func (c *TaskController) UpdateTask(w http.ResponseWriter, r *http.Request) { 57 | id, err := strconv.ParseInt(r.URL.Query().Get("id"), 10, 64) 58 | if err != nil { 59 | c.writeJSON(w, model.Error(400, "Invalid task ID")) 60 | return 61 | } 62 | 63 | // 获取任务 64 | task, err := c.taskRepo.FindByID(id) 65 | if err != nil { 66 | c.writeJSON(w, model.Error(500, "Failed to find task: "+err.Error())) 67 | return 68 | } 69 | if task == nil { 70 | c.writeJSON(w, model.Error(404, "Task not found")) 71 | return 72 | } 73 | 74 | // 解析更新数据 75 | var updatedTask entity.BackupTask 76 | if err := json.NewDecoder(r.Body).Decode(&updatedTask); err != nil { 77 | c.writeJSON(w, model.Error(400, "Invalid request: "+err.Error())) 78 | return 79 | } 80 | 81 | // 更新数据 82 | updatedTask.ID = id 83 | if err := c.taskRepo.Update(&updatedTask); err != nil { 84 | c.writeJSON(w, model.Error(500, "Failed to update task: "+err.Error())) 85 | return 86 | } 87 | 88 | // 更新调度 89 | c.scheduler.RemoveTask(id) 90 | if updatedTask.Enabled { 91 | if err := c.scheduler.AddTask(&updatedTask); err != nil { 92 | c.writeJSON(w, model.Error(500, "Failed to schedule task: "+err.Error())) 93 | return 94 | } 95 | } 96 | 97 | c.writeJSON(w, model.Success(updatedTask)) 98 | } 99 | 100 | // DeleteTask 删除任务 101 | func (c *TaskController) DeleteTask(w http.ResponseWriter, r *http.Request) { 102 | id, err := strconv.ParseInt(r.URL.Query().Get("id"), 10, 64) 103 | if err != nil { 104 | c.writeJSON(w, model.Error(400, "Invalid task ID")) 105 | return 106 | } 107 | 108 | // 从调度器中移除 109 | c.scheduler.RemoveTask(id) 110 | 111 | // 开始事务 112 | tx := repository.GetDB().Begin() 113 | if tx.Error != nil { 114 | c.writeJSON(w, model.Error(500, "开始事务失败: "+tx.Error.Error())) 115 | return 116 | } 117 | 118 | // 删除关联的所有备份记录 119 | if err := tx.Where("task_id = ?", id).Delete(&entity.BackupRecord{}).Error; err != nil { 120 | tx.Rollback() 121 | c.writeJSON(w, model.Error(500, "删除任务关联的备份记录失败: "+err.Error())) 122 | return 123 | } 124 | 125 | // 删除任务 126 | if err := tx.Delete(&entity.BackupTask{}, id).Error; err != nil { 127 | tx.Rollback() 128 | c.writeJSON(w, model.Error(500, "删除任务失败: "+err.Error())) 129 | return 130 | } 131 | 132 | // 提交事务 133 | if err := tx.Commit().Error; err != nil { 134 | c.writeJSON(w, model.Error(500, "提交事务失败: "+err.Error())) 135 | return 136 | } 137 | 138 | c.writeJSON(w, model.Success(nil)) 139 | } 140 | 141 | // GetTask 获取任务 142 | func (c *TaskController) GetTask(w http.ResponseWriter, r *http.Request) { 143 | id, err := strconv.ParseInt(r.URL.Query().Get("id"), 10, 64) 144 | if err != nil { 145 | c.writeJSON(w, model.Error(400, "Invalid task ID")) 146 | return 147 | } 148 | 149 | task, err := c.taskRepo.FindByID(id) 150 | if err != nil { 151 | c.writeJSON(w, model.Error(500, "Failed to find task: "+err.Error())) 152 | return 153 | } 154 | if task == nil { 155 | c.writeJSON(w, model.Error(404, "Task not found")) 156 | return 157 | } 158 | 159 | c.writeJSON(w, model.Success(task)) 160 | } 161 | 162 | // GetAllTasks 获取所有任务 163 | func (c *TaskController) GetAllTasks(w http.ResponseWriter, r *http.Request) { 164 | // 获取分页参数 165 | page, err := strconv.Atoi(r.URL.Query().Get("page")) 166 | if err != nil || page < 1 { 167 | page = 1 168 | } 169 | 170 | pageSize, err := strconv.Atoi(r.URL.Query().Get("pageSize")) 171 | if err != nil || pageSize < 1 { 172 | pageSize = 10 173 | } 174 | 175 | // 检查是否请求不分页的数据 176 | noPagination := r.URL.Query().Get("noPagination") == "true" 177 | 178 | var tasks []*entity.BackupTask 179 | var total int64 180 | var err2 error 181 | 182 | if noPagination { 183 | // 不分页,获取所有任务 184 | tasks, err2 = c.taskRepo.FindAll() 185 | if err2 != nil { 186 | c.writeJSON(w, model.Error(500, "Failed to find tasks: "+err2.Error())) 187 | return 188 | } 189 | 190 | // 给每个任务添加下一次执行时间 191 | c.enrichTasksWithNextExecutionTime(tasks) 192 | 193 | c.writeJSON(w, model.Success(tasks)) 194 | } else { 195 | // 获取分页数据 196 | tasks, err2 = c.taskRepo.FindAllPaginated(page, pageSize) 197 | if err2 != nil { 198 | c.writeJSON(w, model.Error(500, "Failed to find tasks: "+err2.Error())) 199 | return 200 | } 201 | 202 | // 获取总任务数 203 | total, err2 = c.taskRepo.CountAll() 204 | if err2 != nil { 205 | c.writeJSON(w, model.Error(500, "Failed to count tasks: "+err2.Error())) 206 | return 207 | } 208 | 209 | // 给每个任务添加下一次执行时间 210 | c.enrichTasksWithNextExecutionTime(tasks) 211 | 212 | // 返回分页结果 213 | result := map[string]interface{}{ 214 | "tasks": tasks, 215 | "total": total, 216 | "page": page, 217 | "pageSize": pageSize, 218 | } 219 | 220 | c.writeJSON(w, model.Success(result)) 221 | } 222 | } 223 | 224 | // enrichTasksWithNextExecutionTime 给任务添加下一次执行时间 225 | func (c *TaskController) enrichTasksWithNextExecutionTime(tasks []*entity.BackupTask) { 226 | // 为每个任务添加下一次执行时间 227 | for _, task := range tasks { 228 | // 任务已启用时才计算下一次执行时间 229 | if task.Enabled { 230 | nextTime := c.scheduler.GetNextExecutionTime(task.ID) 231 | if nextTime != nil { 232 | // 添加到任务的额外数据中 233 | if task.ExtraData == nil { 234 | task.ExtraData = make(map[string]interface{}) 235 | } 236 | task.ExtraData["nextExecutionTime"] = nextTime.Format("2006-01-02 15:04:05") 237 | } 238 | } else { 239 | // 如果任务未启用,设置为已禁用 240 | if task.ExtraData == nil { 241 | task.ExtraData = make(map[string]interface{}) 242 | } 243 | task.ExtraData["nextExecutionTime"] = "已禁用" 244 | } 245 | } 246 | } 247 | 248 | // ExecuteTask 立即执行任务 249 | func (c *TaskController) ExecuteTask(w http.ResponseWriter, r *http.Request) { 250 | id, err := strconv.ParseInt(r.URL.Query().Get("id"), 10, 64) 251 | if err != nil { 252 | c.writeJSON(w, model.Error(400, "Invalid task ID")) 253 | return 254 | } 255 | 256 | // 检查任务是否存在 257 | task, err := c.taskRepo.FindByID(id) 258 | if err != nil { 259 | c.writeJSON(w, model.Error(500, "Failed to find task: "+err.Error())) 260 | return 261 | } 262 | if task == nil { 263 | c.writeJSON(w, model.Error(404, "Task not found")) 264 | return 265 | } 266 | 267 | // 立即执行 268 | if err := c.scheduler.ExecuteTaskNow(id); err != nil { 269 | c.writeJSON(w, model.Error(500, "Failed to execute task: "+err.Error())) 270 | return 271 | } 272 | 273 | c.writeJSON(w, model.Success(nil)) 274 | } 275 | 276 | // UpdateTaskEnabled 更新任务启用状态 277 | func (c *TaskController) UpdateTaskEnabled(w http.ResponseWriter, r *http.Request) { 278 | id, err := strconv.ParseInt(r.URL.Query().Get("id"), 10, 64) 279 | if err != nil { 280 | c.writeJSON(w, model.Error(400, "无效的任务ID")) 281 | return 282 | } 283 | 284 | // 获取请求中的enabled状态 285 | enabledStr := r.URL.Query().Get("enabled") 286 | if enabledStr == "" { 287 | c.writeJSON(w, model.Error(400, "缺少启用状态参数")) 288 | return 289 | } 290 | 291 | enabled := enabledStr == "true" 292 | 293 | // 获取任务 294 | task, err := c.taskRepo.FindByID(id) 295 | if err != nil { 296 | c.writeJSON(w, model.Error(500, "查找任务失败: "+err.Error())) 297 | return 298 | } 299 | if task == nil { 300 | c.writeJSON(w, model.Error(404, "任务不存在")) 301 | return 302 | } 303 | 304 | // 如果状态没有变化,直接返回成功 305 | if task.Enabled == enabled { 306 | c.writeJSON(w, model.Success(task)) 307 | return 308 | } 309 | 310 | // 更新任务启用状态 311 | task.Enabled = enabled 312 | task.UpdatedAt = time.Now() 313 | 314 | if err := c.taskRepo.Update(task); err != nil { 315 | c.writeJSON(w, model.Error(500, "更新任务状态失败: "+err.Error())) 316 | return 317 | } 318 | 319 | // 处理调度器中的任务 320 | if enabled { 321 | // 启用任务,添加到调度器 322 | if err := c.scheduler.AddTask(task); err != nil { 323 | c.writeJSON(w, model.Error(500, "添加任务到调度器失败: "+err.Error())) 324 | return 325 | } 326 | log.Printf("任务 %d 已启用并添加到调度器", id) 327 | } else { 328 | // 禁用任务,从调度器移除 329 | c.scheduler.RemoveTask(id) 330 | log.Printf("任务 %d 已禁用并从调度器移除", id) 331 | } 332 | 333 | c.writeJSON(w, model.Success(task)) 334 | } 335 | 336 | // GetTaskNextExecutionTime 获取任务的下一次执行时间 337 | func (c *TaskController) GetTaskNextExecutionTime(w http.ResponseWriter, r *http.Request) { 338 | id, err := strconv.ParseInt(r.URL.Query().Get("id"), 10, 64) 339 | if err != nil { 340 | c.writeJSON(w, model.Error(400, "无效的任务ID")) 341 | return 342 | } 343 | 344 | // 获取任务 345 | task, err := c.taskRepo.FindByID(id) 346 | if err != nil { 347 | c.writeJSON(w, model.Error(500, "查找任务失败: "+err.Error())) 348 | return 349 | } 350 | if task == nil { 351 | c.writeJSON(w, model.Error(404, "任务不存在")) 352 | return 353 | } 354 | 355 | // 获取下一次执行时间 356 | nextTime := c.scheduler.GetNextExecutionTime(id) 357 | if nextTime == nil { 358 | c.writeJSON(w, model.Success(nil)) 359 | return 360 | } 361 | 362 | c.writeJSON(w, model.Success(nextTime.Format("2006-01-02 15:04:05"))) 363 | } 364 | 365 | // 写入JSON响应 366 | func (c *TaskController) writeJSON(w http.ResponseWriter, data interface{}) { 367 | w.Header().Set("Content-Type", "application/json") 368 | json.NewEncoder(w).Encode(data) 369 | } 370 | -------------------------------------------------------------------------------- /api/middleware/auth_middleware.go: -------------------------------------------------------------------------------- 1 | package middleware 2 | 3 | import ( 4 | "backup-go/api/controller" 5 | "backup-go/model" 6 | "backup-go/service/config" 7 | "encoding/json" 8 | "net/http" 9 | "strings" 10 | ) 11 | 12 | // AuthMiddleware 身份验证中间件 13 | func AuthMiddleware(next http.Handler) http.Handler { 14 | return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 15 | // 跳过登录接口的身份验证 16 | if r.URL.Path == "/api/auth/login" || r.URL.Path == "/api/auth/check" || r.URL.Path == "/api/records/download" { 17 | next.ServeHTTP(w, r) 18 | return 19 | } 20 | 21 | // 检查是否启用密码保护 22 | configService := config.NewConfigService() 23 | passwordConfig, err := configService.GetConfigByKey("system.password") 24 | 25 | // 如果没有设置密码或配置不存在,不需要验证 26 | if err != nil || passwordConfig.ConfigValue == "" { 27 | next.ServeHTTP(w, r) 28 | return 29 | } 30 | 31 | // 获取并验证token 32 | authHeader := r.Header.Get("Authorization") 33 | 34 | if authHeader == "" { 35 | // 没有提供token,返回未授权错误 36 | w.Header().Set("Content-Type", "application/json") 37 | w.WriteHeader(http.StatusUnauthorized) 38 | json.NewEncoder(w).Encode(model.ErrorWithCode(401, "未授权访问")) 39 | return 40 | } 41 | 42 | // 验证token格式 43 | if !strings.HasPrefix(authHeader, "Bearer ") { 44 | w.Header().Set("Content-Type", "application/json") 45 | w.WriteHeader(http.StatusUnauthorized) 46 | json.NewEncoder(w).Encode(model.ErrorWithCode(401, "无效的凭证格式")) 47 | return 48 | } 49 | 50 | // 提取token值(去除Bearer前缀) 51 | token := strings.TrimPrefix(authHeader, "Bearer ") 52 | 53 | // 验证token是否有效 54 | if !controller.IsValidToken(token) { 55 | w.Header().Set("Content-Type", "application/json") 56 | w.WriteHeader(http.StatusUnauthorized) 57 | json.NewEncoder(w).Encode(model.ErrorWithCode(401, "无效的凭证或已过期")) 58 | return 59 | } 60 | 61 | next.ServeHTTP(w, r) 62 | }) 63 | } 64 | -------------------------------------------------------------------------------- /api/middleware/middleware.go: -------------------------------------------------------------------------------- 1 | package middleware 2 | 3 | import ( 4 | "net/http" 5 | ) 6 | 7 | // CorsMiddleware 跨域中间件 8 | func CorsMiddleware(next http.Handler) http.Handler { 9 | return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 10 | // 允许所有源 11 | w.Header().Set("Access-Control-Allow-Origin", "*") 12 | // 允许的请求方法 13 | w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS") 14 | // 允许的请求头 15 | w.Header().Set("Access-Control-Allow-Headers", "Content-Type, Authorization") 16 | // 允许暴露的响应头 17 | w.Header().Set("Access-Control-Expose-Headers", "Content-Length, Access-Control-Allow-Origin") 18 | // 预检请求的缓存时间 19 | w.Header().Set("Access-Control-Max-Age", "86400") 20 | 21 | // 处理OPTIONS请求 22 | if r.Method == "OPTIONS" { 23 | w.WriteHeader(http.StatusOK) 24 | return 25 | } 26 | 27 | next.ServeHTTP(w, r) 28 | }) 29 | } 30 | 31 | // LoggingMiddleware 日志中间件 32 | func LoggingMiddleware(next http.Handler) http.Handler { 33 | return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 34 | // 直接调用下一个处理器,不记录日志 35 | next.ServeHTTP(w, r) 36 | }) 37 | } 38 | 39 | // responseWriterWrapper 包装ResponseWriter以获取状态码 40 | type responseWriterWrapper struct { 41 | http.ResponseWriter 42 | statusCode int 43 | } 44 | 45 | // WriteHeader 重写WriteHeader方法以捕获状态码 46 | func (w *responseWriterWrapper) WriteHeader(statusCode int) { 47 | w.statusCode = statusCode 48 | w.ResponseWriter.WriteHeader(statusCode) 49 | } 50 | -------------------------------------------------------------------------------- /api/router/router.go: -------------------------------------------------------------------------------- 1 | package router 2 | 3 | import ( 4 | "backup-go/api/controller" 5 | "backup-go/api/middleware" 6 | "net/http" 7 | ) 8 | 9 | // SetupRouter 设置路由 10 | func SetupRouter() http.Handler { 11 | taskController := controller.NewTaskController() 12 | recordController := controller.NewRecordController() 13 | configController := controller.NewConfigController() 14 | authController := controller.NewAuthController() 15 | cleanupController := controller.NewCleanupController() 16 | 17 | // 创建路由复用器 18 | mux := http.NewServeMux() 19 | 20 | // 静态文件服务 21 | mux.Handle("/", http.FileServer(http.Dir("public"))) 22 | 23 | // 添加匿名访问的API路由 24 | mux.HandleFunc("/api/info", func(w http.ResponseWriter, r *http.Request) { 25 | if r.Method == http.MethodGet { 26 | configController.GetSiteInfo(w, r) 27 | } else { 28 | http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) 29 | } 30 | }) 31 | 32 | // API路由 33 | apiRoutes := http.NewServeMux() 34 | 35 | // 认证相关路由 36 | apiRoutes.HandleFunc("/api/auth/login", func(w http.ResponseWriter, r *http.Request) { 37 | if r.Method == http.MethodPost { 38 | authController.Login(w, r) 39 | } else { 40 | http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) 41 | } 42 | }) 43 | 44 | apiRoutes.HandleFunc("/api/auth/check", func(w http.ResponseWriter, r *http.Request) { 45 | if r.Method == http.MethodGet { 46 | authController.CheckAuth(w, r) 47 | } else { 48 | http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) 49 | } 50 | }) 51 | 52 | // 任务相关路由 53 | apiRoutes.HandleFunc("/api/tasks", func(w http.ResponseWriter, r *http.Request) { 54 | switch r.Method { 55 | case http.MethodGet: 56 | taskController.GetAllTasks(w, r) 57 | case http.MethodPost: 58 | taskController.CreateTask(w, r) 59 | default: 60 | http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) 61 | } 62 | }) 63 | 64 | apiRoutes.HandleFunc("/api/tasks/get", func(w http.ResponseWriter, r *http.Request) { 65 | if r.Method == http.MethodGet { 66 | taskController.GetTask(w, r) 67 | } else { 68 | http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) 69 | } 70 | }) 71 | 72 | apiRoutes.HandleFunc("/api/tasks/update", func(w http.ResponseWriter, r *http.Request) { 73 | if r.Method == http.MethodPost { 74 | taskController.UpdateTask(w, r) 75 | } else { 76 | http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) 77 | } 78 | }) 79 | 80 | apiRoutes.HandleFunc("/api/tasks/delete", func(w http.ResponseWriter, r *http.Request) { 81 | if r.Method == http.MethodPost { 82 | taskController.DeleteTask(w, r) 83 | } else { 84 | http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) 85 | } 86 | }) 87 | 88 | apiRoutes.HandleFunc("/api/tasks/execute", func(w http.ResponseWriter, r *http.Request) { 89 | if r.Method == http.MethodPost { 90 | taskController.ExecuteTask(w, r) 91 | } else { 92 | http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) 93 | } 94 | }) 95 | 96 | apiRoutes.HandleFunc("/api/tasks/updateEnabled", func(w http.ResponseWriter, r *http.Request) { 97 | if r.Method == http.MethodPost || r.Method == http.MethodGet { 98 | taskController.UpdateTaskEnabled(w, r) 99 | } else { 100 | http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) 101 | } 102 | }) 103 | 104 | apiRoutes.HandleFunc("/api/tasks/nextExecutionTime", func(w http.ResponseWriter, r *http.Request) { 105 | if r.Method == http.MethodGet { 106 | taskController.GetTaskNextExecutionTime(w, r) 107 | } else { 108 | http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) 109 | } 110 | }) 111 | 112 | // 记录相关路由 113 | apiRoutes.HandleFunc("/api/records", func(w http.ResponseWriter, r *http.Request) { 114 | if r.Method == http.MethodGet { 115 | recordController.GetAllRecords(w, r) 116 | } else { 117 | http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) 118 | } 119 | }) 120 | 121 | apiRoutes.HandleFunc("/api/records/get", func(w http.ResponseWriter, r *http.Request) { 122 | if r.Method == http.MethodGet { 123 | recordController.GetRecord(w, r) 124 | } else { 125 | http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) 126 | } 127 | }) 128 | 129 | apiRoutes.HandleFunc("/api/records/task", func(w http.ResponseWriter, r *http.Request) { 130 | if r.Method == http.MethodGet { 131 | recordController.GetTaskRecords(w, r) 132 | } else { 133 | http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) 134 | } 135 | }) 136 | 137 | apiRoutes.HandleFunc("/api/records/download", func(w http.ResponseWriter, r *http.Request) { 138 | if r.Method == http.MethodGet { 139 | recordController.DownloadBackup(w, r) 140 | } else { 141 | http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) 142 | } 143 | }) 144 | 145 | apiRoutes.HandleFunc("/api/records/delete", func(w http.ResponseWriter, r *http.Request) { 146 | if r.Method == http.MethodPost { 147 | recordController.DeleteRecord(w, r) 148 | } else { 149 | http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) 150 | } 151 | }) 152 | 153 | // 配置相关路由 154 | apiRoutes.HandleFunc("/api/configs", func(w http.ResponseWriter, r *http.Request) { 155 | switch r.Method { 156 | case http.MethodGet: 157 | configController.GetConfigList(w, r) 158 | case http.MethodPost: 159 | configController.CreateConfig(w, r) 160 | default: 161 | http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) 162 | } 163 | }) 164 | 165 | apiRoutes.HandleFunc("/api/configs/get", func(w http.ResponseWriter, r *http.Request) { 166 | if r.Method == http.MethodGet { 167 | configController.GetConfig(w, r) 168 | } else { 169 | http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) 170 | } 171 | }) 172 | 173 | apiRoutes.HandleFunc("/api/configs/getByKey", func(w http.ResponseWriter, r *http.Request) { 174 | if r.Method == http.MethodGet { 175 | configController.GetConfigByKey(w, r) 176 | } else { 177 | http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) 178 | } 179 | }) 180 | 181 | apiRoutes.HandleFunc("/api/configs/update", func(w http.ResponseWriter, r *http.Request) { 182 | if r.Method == http.MethodPost { 183 | configController.UpdateConfig(w, r) 184 | } else { 185 | http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) 186 | } 187 | }) 188 | 189 | apiRoutes.HandleFunc("/api/configs/delete", func(w http.ResponseWriter, r *http.Request) { 190 | if r.Method == http.MethodPost { 191 | configController.DeleteConfig(w, r) 192 | } else { 193 | http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) 194 | } 195 | }) 196 | 197 | apiRoutes.HandleFunc("/api/configs/testWebhook", func(w http.ResponseWriter, r *http.Request) { 198 | if r.Method == http.MethodPost { 199 | configController.TestWebhook(w, r) 200 | } else { 201 | http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) 202 | } 203 | }) 204 | 205 | // 清理功能路由 206 | apiRoutes.HandleFunc("/api/cleanup/execute", func(w http.ResponseWriter, r *http.Request) { 207 | if r.Method == http.MethodPost { 208 | cleanupController.ExecuteCleanup(w, r) 209 | } else { 210 | http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) 211 | } 212 | }) 213 | 214 | // 设置API中间件 215 | handler := middleware.CorsMiddleware(apiRoutes) 216 | handler = middleware.LoggingMiddleware(handler) 217 | handler = middleware.AuthMiddleware(handler) 218 | 219 | // 注册API路由到主路由 220 | mux.Handle("/api/", handler) 221 | 222 | return mux 223 | } 224 | -------------------------------------------------------------------------------- /config.yaml: -------------------------------------------------------------------------------- 1 | # 服务器配置 2 | server: 3 | port: 8080 4 | 5 | # 数据库配置 6 | database: 7 | type: sqlite # mysql或sqlite 8 | # host: 127.0.0.1 9 | # username: root 10 | # password: root 11 | # port: 3306 12 | # name: backup_go 13 | -------------------------------------------------------------------------------- /config/config.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | import ( 4 | "backup-go/entity" 5 | "fmt" 6 | "os" 7 | "sync" 8 | 9 | "github.com/glebarez/sqlite" 10 | "gopkg.in/yaml.v2" 11 | "gorm.io/driver/mysql" 12 | "gorm.io/gorm" 13 | "gorm.io/gorm/logger" 14 | ) 15 | 16 | // 全局配置和数据库连接 17 | var ( 18 | appConfig *Configuration 19 | db *gorm.DB 20 | configMux sync.RWMutex 21 | ) 22 | 23 | // Configuration 配置结构 24 | type Configuration struct { 25 | Server struct { 26 | Host string `yaml:"host"` 27 | Port int `yaml:"port"` 28 | } `yaml:"server"` 29 | Database struct { 30 | Type string `yaml:"type"` 31 | Host string `yaml:"host"` 32 | Port int `yaml:"port"` 33 | User string `yaml:"user"` 34 | Password string `yaml:"password"` 35 | Db string `yaml:"db"` 36 | } `yaml:"database"` 37 | } 38 | 39 | // LoadConfig 加载配置文件 40 | func LoadConfig(configPath string) error { 41 | configMux.Lock() 42 | defer configMux.Unlock() 43 | 44 | // 设置默认配置 45 | setDefaultConfig() 46 | 47 | // 尝试从文件加载配置 48 | if _, err := os.Stat(configPath); err == nil { 49 | file, err := os.ReadFile(configPath) 50 | if err != nil { 51 | return fmt.Errorf("读取配置文件失败: %w", err) 52 | } 53 | 54 | err = yaml.Unmarshal(file, &appConfig) 55 | if err != nil { 56 | return fmt.Errorf("解析配置文件失败: %w", err) 57 | } 58 | } 59 | 60 | return nil 61 | } 62 | 63 | // 设置默认配置 64 | func setDefaultConfig() { 65 | appConfig = &Configuration{} 66 | appConfig.Server.Host = "localhost" 67 | appConfig.Server.Port = 8080 68 | 69 | appConfig.Database.Type = "sqlite" 70 | appConfig.Database.Host = "localhost" 71 | appConfig.Database.Port = 3306 72 | appConfig.Database.User = "root" 73 | appConfig.Database.Password = "password" 74 | appConfig.Database.Db = "backup_go" 75 | 76 | } 77 | 78 | // Get 获取配置 79 | func Get() *Configuration { 80 | configMux.RLock() 81 | defer configMux.RUnlock() 82 | return appConfig 83 | } 84 | 85 | // GetDB 获取数据库连接 86 | func GetDB() *gorm.DB { 87 | return db 88 | } 89 | 90 | // InitDB 初始化数据库连接 91 | func InitDB() error { 92 | var err error 93 | 94 | // 创建GORM的静默日志配置 95 | gormConfig := &gorm.Config{ 96 | Logger: logger.Default.LogMode(logger.Silent), 97 | } 98 | 99 | switch appConfig.Database.Type { 100 | case "mysql": 101 | dsn := fmt.Sprintf("%s:%s@tcp(%s:%d)/%s?charset=utf8mb4&parseTime=True&loc=Local", 102 | appConfig.Database.User, 103 | appConfig.Database.Password, 104 | appConfig.Database.Host, 105 | appConfig.Database.Port, 106 | appConfig.Database.Db, 107 | ) 108 | fmt.Printf("连接MySQL数据库: %s\n", dsn) 109 | db, err = gorm.Open(mysql.Open(dsn), gormConfig) 110 | case "sqlite": 111 | 112 | fmt.Printf("连接SQLite数据库: %s\n", "backup.db") 113 | db, err = gorm.Open(sqlite.Open("backup.db"), gormConfig) 114 | default: 115 | return fmt.Errorf("不支持的数据库类型: %s", appConfig.Database.Type) 116 | } 117 | 118 | if err != nil { 119 | return fmt.Errorf("连接数据库失败: %w", err) 120 | } 121 | 122 | return nil 123 | } 124 | 125 | // MigrateDB 执行数据库迁移 126 | func MigrateDB() error { 127 | // 自动迁移表结构 128 | err := db.AutoMigrate( 129 | &entity.BackupTask{}, 130 | &entity.BackupRecord{}, 131 | &entity.SystemConfig{}, 132 | ) 133 | if err != nil { 134 | return fmt.Errorf("数据表迁移失败: %w", err) 135 | } 136 | return nil 137 | } 138 | -------------------------------------------------------------------------------- /entity/backup.go: -------------------------------------------------------------------------------- 1 | package entity 2 | 3 | import ( 4 | "time" 5 | ) 6 | 7 | // BackupType 备份类型 8 | type BackupType string 9 | 10 | const ( 11 | DatabaseBackup BackupType = "database" // 数据库备份 12 | FileBackup BackupType = "file" // 文件备份 13 | ConfigBackup BackupType = "config" // 配置文件备份 14 | ) 15 | 16 | // BackupStatus 备份状态 17 | type BackupStatus string 18 | 19 | const ( 20 | StatusPending BackupStatus = "pending" // 等待中 21 | StatusRunning BackupStatus = "running" // 执行中 22 | StatusSuccess BackupStatus = "success" // 成功 23 | StatusFailed BackupStatus = "failed" // 失败 24 | StatusCancelled BackupStatus = "cancelled" // 已取消 25 | StatusCleaned BackupStatus = "cleaned" // 已清理 26 | ) 27 | 28 | // StorageType 存储类型 29 | type StorageType string 30 | 31 | const ( 32 | LocalStorage StorageType = "local" // 本地存储 33 | S3Storage StorageType = "s3" // S3协议存储 34 | ) 35 | 36 | // SystemConfig 系统配置 37 | type SystemConfig struct { 38 | ID uint `json:"id" gorm:"primaryKey;autoIncrement"` 39 | ConfigKey string `json:"configKey" gorm:"type:varchar(100);not null;uniqueIndex"` // 配置键 40 | ConfigValue string `json:"configValue" gorm:"type:text;not null"` // 配置值 41 | Description string `json:"description" gorm:"type:varchar(255);not null;default:''"` // 配置描述 42 | CreatedAt time.Time `json:"createdAt" gorm:"type:datetime;not null;default:CURRENT_TIMESTAMP"` // 创建时间 43 | UpdatedAt time.Time `json:"updatedAt" gorm:"type:datetime;not null"` // 更新时间 44 | } 45 | 46 | // TableName 指定表名 47 | func (SystemConfig) TableName() string { 48 | return "system_configs" 49 | } 50 | 51 | // BackupTask 备份任务 52 | type BackupTask struct { 53 | ID int64 `json:"id" gorm:"primaryKey;autoIncrement"` 54 | Name string `json:"name" gorm:"type:varchar(100);not null"` // 任务名称 55 | Type BackupType `json:"type" gorm:"type:varchar(20);not null"` // 备份类型 56 | SourceInfo string `json:"sourceInfo" gorm:"type:text;not null"` // 源信息,JSON格式,根据不同类型包含不同内容 57 | Schedule string `json:"schedule" gorm:"type:varchar(100);not null"` // Cron表达式 58 | Enabled bool `json:"enabled" gorm:"type:tinyint(1);not null;default:1"` // 是否启用 59 | CreatedAt time.Time `json:"createdAt" gorm:"type:datetime;not null;default:CURRENT_TIMESTAMP"` // 创建时间 60 | UpdatedAt time.Time `json:"updatedAt" gorm:"type:datetime;not null"` // 更新时间 61 | ExtraData map[string]interface{} `json:"extraData" gorm:"-"` // 额外数据,不持久化到数据库 62 | } 63 | 64 | // TableName 指定表名 65 | func (BackupTask) TableName() string { 66 | return "backup_tasks" 67 | } 68 | 69 | // DatabaseSourceInfo 数据库源信息 70 | type DatabaseSourceInfo struct { 71 | Type string `json:"type"` // 数据库类型 72 | Host string `json:"host"` // 主机 73 | Port int `json:"port"` // 端口 74 | User string `json:"user"` // 用户名 75 | Password string `json:"password"` // 密码 76 | Database string `json:"database"` // 数据库名,为空或"all"时表示备份所有数据库 77 | } 78 | 79 | // FileSourceInfo 文件源信息 80 | type FileSourceInfo struct { 81 | Paths []string `json:"paths"` // 文件或目录路径 82 | } 83 | 84 | // BackupRecord 备份记录 85 | type BackupRecord struct { 86 | ID int64 `json:"id" gorm:"primaryKey;autoIncrement"` 87 | TaskID int64 `json:"taskId" gorm:"not null;index"` // 任务ID 88 | TaskName string `json:"taskName" gorm:"-"` // 任务名称(不映射到数据库) 89 | Status BackupStatus `json:"status" gorm:"type:varchar(20);not null"` // 状态 90 | StartTime time.Time `json:"startTime" gorm:"type:datetime;not null"` // 开始时间 91 | EndTime time.Time `json:"endTime" gorm:"type:datetime;not null;default:'1970-01-01 00:00:00'"` // 结束时间 92 | FileSize int64 `json:"fileSize" gorm:"not null;default:0"` // 备份文件大小,单位字节 93 | FilePath string `json:"filePath" gorm:"type:varchar(255);not null;default:''"` // 文件路径 94 | StorageType StorageType `json:"storageType" gorm:"type:varchar(20);not null;default:'local'"` // 存储类型 95 | ErrorMessage string `json:"errorMessage" gorm:"type:text;not null"` // 错误信息 96 | BackupVersion string `json:"backupVersion" gorm:"type:varchar(50);not null;default:''"` // 备份版本 97 | CreatedAt time.Time `json:"createdAt" gorm:"type:datetime;not null;default:CURRENT_TIMESTAMP"` // 创建时间 98 | UpdatedAt time.Time `json:"updatedAt" gorm:"type:datetime;not null"` // 更新时间 99 | } 100 | 101 | // TableName 指定表名 102 | func (BackupRecord) TableName() string { 103 | return "backup_records" 104 | } 105 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module backup-go 2 | 3 | go 1.21 4 | 5 | require ( 6 | github.com/aws/aws-sdk-go v1.49.4 7 | github.com/glebarez/sqlite v1.11.0 8 | github.com/go-resty/resty/v2 v2.16.5 9 | github.com/google/uuid v1.6.0 10 | github.com/robfig/cron/v3 v3.0.1 11 | gopkg.in/yaml.v2 v2.4.0 12 | gorm.io/driver/mysql v1.5.6 13 | gorm.io/gorm v1.25.7 14 | ) 15 | 16 | require ( 17 | github.com/dustin/go-humanize v1.0.1 // indirect 18 | github.com/glebarez/go-sqlite v1.21.2 // indirect 19 | github.com/go-sql-driver/mysql v1.7.1 // indirect 20 | github.com/jinzhu/inflection v1.0.0 // indirect 21 | github.com/jinzhu/now v1.1.5 // indirect 22 | github.com/jmespath/go-jmespath v0.4.0 // indirect 23 | github.com/mattn/go-isatty v0.0.17 // indirect 24 | github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect 25 | golang.org/x/net v0.33.0 // indirect 26 | golang.org/x/sys v0.28.0 // indirect 27 | modernc.org/libc v1.22.5 // indirect 28 | modernc.org/mathutil v1.5.0 // indirect 29 | modernc.org/memory v1.5.0 // indirect 30 | modernc.org/sqlite v1.23.1 // indirect 31 | ) 32 | 33 | replace gorm.io/driver/sqlite => github.com/glebarez/sqlite v1.11.0 34 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/aws/aws-sdk-go v1.49.4 h1:qiXsqEeLLhdLgUIyfr5ot+N/dGPWALmtM1SetRmbUlY= 2 | github.com/aws/aws-sdk-go v1.49.4/go.mod h1:LF8svs817+Nz+DmiMQKTO3ubZ/6IaTpq3TjupRn3Eqk= 3 | github.com/davecgh/go-spew v1.1.0 h1:ZDRjVQ15GmhC3fiQ8ni8+OwkZQO4DARzQgrnXU1Liz8= 4 | github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 5 | github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= 6 | github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= 7 | github.com/glebarez/go-sqlite v1.21.2 h1:3a6LFC4sKahUunAmynQKLZceZCOzUthkRkEAl9gAXWo= 8 | github.com/glebarez/go-sqlite v1.21.2/go.mod h1:sfxdZyhQjTM2Wry3gVYWaW072Ri1WMdWJi0k6+3382k= 9 | github.com/glebarez/sqlite v1.11.0 h1:wSG0irqzP6VurnMEpFGer5Li19RpIRi2qvQz++w0GMw= 10 | github.com/glebarez/sqlite v1.11.0/go.mod h1:h8/o8j5wiAsqSPoWELDUdJXhjAhsVliSn7bWZjOhrgQ= 11 | github.com/go-resty/resty/v2 v2.16.5 h1:hBKqmWrr7uRc3euHVqmh1HTHcKn99Smr7o5spptdhTM= 12 | github.com/go-resty/resty/v2 v2.16.5/go.mod h1:hkJtXbA2iKHzJheXYvQ8snQES5ZLGKMwQ07xAwp/fiA= 13 | github.com/go-sql-driver/mysql v1.7.0/go.mod h1:OXbVy3sEdcQ2Doequ6Z5BW6fXNQTmx+9S1MCJN5yJMI= 14 | github.com/go-sql-driver/mysql v1.7.1 h1:lUIinVbN1DY0xBg0eMOzmmtGoHwWBbvnWubQUrtU8EI= 15 | github.com/go-sql-driver/mysql v1.7.1/go.mod h1:OXbVy3sEdcQ2Doequ6Z5BW6fXNQTmx+9S1MCJN5yJMI= 16 | github.com/google/pprof v0.0.0-20221118152302-e6195bd50e26 h1:Xim43kblpZXfIBQsbuBVKCudVG457BR2GZFIz3uw3hQ= 17 | github.com/google/pprof v0.0.0-20221118152302-e6195bd50e26/go.mod h1:dDKJzRmX4S37WGHujM7tX//fmj1uioxKzKxz3lo4HJo= 18 | github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= 19 | github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= 20 | github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E= 21 | github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc= 22 | github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ= 23 | github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8= 24 | github.com/jmespath/go-jmespath v0.4.0 h1:BEgLn5cpjn8UN1mAw4NjwDrS35OdebyEtFe+9YPoQUg= 25 | github.com/jmespath/go-jmespath v0.4.0/go.mod h1:T8mJZnbsbmF+m6zOOFylbeCJqk5+pHWvzYPziyZiYoo= 26 | github.com/jmespath/go-jmespath/internal/testify v1.5.1 h1:shLQSRRSCCPj3f2gpwzGwWFoC7ycTf1rcQZHOlsJ6N8= 27 | github.com/jmespath/go-jmespath/internal/testify v1.5.1/go.mod h1:L3OGu8Wl2/fWfCI6z80xFu9LTZmf1ZRjMHUOPmWr69U= 28 | github.com/mattn/go-isatty v0.0.17 h1:BTarxUcIeDqL27Mc+vyvdWYSL28zpIhv3RoTdsLMPng= 29 | github.com/mattn/go-isatty v0.0.17/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= 30 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 31 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 32 | github.com/remyoudompheng/bigfft v0.0.0-20200410134404-eec4a21b6bb0/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= 33 | github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= 34 | github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= 35 | github.com/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs= 36 | github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro= 37 | github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= 38 | golang.org/x/net v0.33.0 h1:74SYHlV8BIgHIFC/LrYkOGIwL19eTYXQ5wc6TBuO36I= 39 | golang.org/x/net v0.33.0/go.mod h1:HXLR5J+9DxmrqMwG9qjGCxZ+zKXxBru04zlTvWlWuN4= 40 | golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 41 | golang.org/x/sys v0.28.0 h1:Fksou7UEQUWlKvIdsqzJmUmCX3cZuD2+P3XyyzwMhlA= 42 | golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= 43 | golang.org/x/text v0.21.0 h1:zyQAAkrwaneQ066sspRyJaG9VNi/YJ1NfzcGB3hZ/qo= 44 | golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ= 45 | golang.org/x/time v0.6.0 h1:eTDhh4ZXt5Qf0augr54TN6suAUudPcawVZeIAPU7D4U= 46 | golang.org/x/time v0.6.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM= 47 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= 48 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 49 | gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= 50 | gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= 51 | gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= 52 | gorm.io/driver/mysql v1.5.6 h1:Ld4mkIickM+EliaQZQx3uOJDJHtrd70MxAUqWqlx3Y8= 53 | gorm.io/driver/mysql v1.5.6/go.mod h1:sEtPWMiqiN1N1cMXoXmBbd8C6/l+TESwriotuRRpkDM= 54 | gorm.io/gorm v1.25.7 h1:VsD6acwRjz2zFxGO50gPO6AkNs7KKnvfzUjHQhZDz/A= 55 | gorm.io/gorm v1.25.7/go.mod h1:hbnx/Oo0ChWMn1BIhpy1oYozzpM15i4YPuHDmfYtwg8= 56 | modernc.org/libc v1.22.5 h1:91BNch/e5B0uPbJFgqbxXuOnxBQjlS//icfQEGmvyjE= 57 | modernc.org/libc v1.22.5/go.mod h1:jj+Z7dTNX8fBScMVNRAYZ/jF91K8fdT2hYMThc3YjBY= 58 | modernc.org/mathutil v1.5.0 h1:rV0Ko/6SfM+8G+yKiyI830l3Wuz1zRutdslNoQ0kfiQ= 59 | modernc.org/mathutil v1.5.0/go.mod h1:mZW8CKdRPY1v87qxC/wUdX5O1qDzXMP5TH3wjfpga6E= 60 | modernc.org/memory v1.5.0 h1:N+/8c5rE6EqugZwHii4IFsaJ7MUhoWX07J5tC/iI5Ds= 61 | modernc.org/memory v1.5.0/go.mod h1:PkUhL0Mugw21sHPeskwZW4D6VscE/GQJOnIpCnW6pSU= 62 | modernc.org/sqlite v1.23.1 h1:nrSBg4aRQQwq59JpvGEQ15tNxoO5pX/kUjcRNwSAGQM= 63 | modernc.org/sqlite v1.23.1/go.mod h1:OrDj17Mggn6MhE+iPbBNf7RGKODDE9NFT0f3EwDzJqk= 64 | -------------------------------------------------------------------------------- /main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "backup-go/api/router" 5 | "backup-go/config" 6 | backupService "backup-go/service/backup" 7 | "backup-go/service/cleanup" 8 | configService "backup-go/service/config" 9 | "backup-go/service/scheduler" 10 | "flag" 11 | "fmt" 12 | "log" 13 | "net/http" 14 | ) 15 | 16 | func main() { 17 | // 解析命令行参数 18 | configPath := flag.String("config", "config.yaml", "配置文件路径") 19 | flag.Parse() 20 | 21 | // 加载配置 22 | if err := config.LoadConfig(*configPath); err != nil { 23 | log.Fatalf("加载配置文件失败: %v", err) 24 | } 25 | 26 | // 连接数据库 27 | if err := config.InitDB(); err != nil { 28 | log.Fatalf("初始化数据库失败: %v", err) 29 | } 30 | 31 | // 执行数据库迁移 32 | if err := config.MigrateDB(); err != nil { 33 | log.Fatalf("数据库迁移失败: %v", err) 34 | } 35 | 36 | // 初始化系统默认配置 37 | cs := configService.NewConfigService() 38 | if err := cs.InitDefaultConfigs(); err != nil { 39 | log.Printf("初始化默认配置失败: %v", err) 40 | } 41 | 42 | // 处理异常状态的备份记录 43 | if err := backupService.InitBackupRecords(); err != nil { 44 | log.Printf("处理异常备份记录失败: %v", err) 45 | } 46 | 47 | // 启动调度器 48 | backupScheduler := scheduler.GetScheduler() 49 | backupScheduler.Start() 50 | 51 | // 启动清理服务 52 | cleanupSvc := cleanup.GetCleanupService() 53 | cleanupSvc.Start() 54 | 55 | // 设置路由 56 | r := router.SetupRouter() 57 | 58 | // 启动HTTP服务 59 | serverAddr := fmt.Sprintf(":%d", config.Get().Server.Port) 60 | log.Printf("HTTP服务启动%s\n", serverAddr) 61 | if err := http.ListenAndServe(serverAddr, r); err != nil { 62 | // 停止调度器 63 | backupScheduler.Stop() 64 | // 停止清理服务 65 | cleanupSvc.Stop() 66 | log.Fatalf("服务启动失败: %v", err) 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /model/response.go: -------------------------------------------------------------------------------- 1 | package model 2 | 3 | import ( 4 | "encoding/json" 5 | "net/http" 6 | "time" 7 | ) 8 | 9 | // Response API通用响应结构 10 | type Response struct { 11 | Code int `json:"code"` // 状态码,200表示成功 12 | Msg string `json:"msg"` // 消息 13 | Data interface{} `json:"data"` // 数据 14 | T int64 `json:"t"` // 时间戳 15 | } 16 | 17 | // NewResponse 创建响应 18 | func NewResponse(code int, msg string, data interface{}) *Response { 19 | return &Response{ 20 | Code: code, 21 | Msg: msg, 22 | Data: data, 23 | T: time.Now().UnixMilli(), 24 | } 25 | } 26 | 27 | // Success 成功响应 28 | func Success(data interface{}) *Response { 29 | return NewResponse(200, "success", data) 30 | } 31 | 32 | // Error 错误响应 33 | func Error(code int, msg string) *Response { 34 | return NewResponse(code, msg, nil) 35 | } 36 | 37 | // SuccessResponse 简化的成功响应 38 | func SuccessResponse(data interface{}) *Response { 39 | return Success(data) 40 | } 41 | 42 | // FailResponse 简化的失败响应 43 | func FailResponse(msg string) *Response { 44 | return Error(400, msg) 45 | } 46 | 47 | // ErrorWithCode 指定代码的错误响应 48 | func ErrorWithCode(code int, msg string) *Response { 49 | return Error(code, msg) 50 | } 51 | 52 | // SuccessWithMsg 带消息的成功响应 53 | func SuccessWithMsg(data interface{}, msg string) *Response { 54 | return &Response{ 55 | Code: 200, 56 | Msg: msg, 57 | Data: data, 58 | T: time.Now().UnixMilli(), 59 | } 60 | } 61 | 62 | // WriteJSON 将结构体转换为JSON输出到响应 63 | func WriteJSON(w http.ResponseWriter, data interface{}) { 64 | w.Header().Set("Content-Type", "application/json") 65 | json.NewEncoder(w).Encode(data) 66 | } 67 | -------------------------------------------------------------------------------- /public/assets/css/style.css: -------------------------------------------------------------------------------- 1 | /* 自定义样式 */ 2 | body { 3 | background-color: #f8f9fa; 4 | } 5 | 6 | .container { 7 | max-width: 1200px; 8 | } 9 | 10 | .navbar { 11 | position: fixed; 12 | top: 0; 13 | left: 0; 14 | width: 100%; 15 | z-index: 1000; 16 | margin-bottom: 20px; 17 | box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1); 18 | } 19 | 20 | /* 小型表格样式 */ 21 | .table { 22 | background-color: #fff; 23 | border-radius: 5px; 24 | box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1); 25 | font-size: 0.875rem; /* 小号字体 */ 26 | } 27 | 28 | .table th, .table td { 29 | padding: 0.5rem; /* 减小单元格内边距 */ 30 | vertical-align: middle; 31 | } 32 | 33 | .table th { 34 | background-color: #f8f9fa; 35 | font-weight: 600; 36 | } 37 | 38 | /* 小型按钮样式 */ 39 | .btn { 40 | font-size: 0.875rem; /* 全局小号按钮 */ 41 | padding: 0.25rem 0.75rem; 42 | } 43 | 44 | .btn-icon { 45 | padding: 0.2rem 0.4rem; 46 | font-size: 0.8rem; 47 | line-height: 1.4; 48 | border-radius: 0.2rem; 49 | margin-right: 3px; 50 | } 51 | 52 | /* 小型分页器样式 */ 53 | .pagination { 54 | font-size: 0.875rem; 55 | } 56 | 57 | .pagination .page-link { 58 | padding: 0.25rem 0.5rem; 59 | } 60 | 61 | .pagination .page-item { 62 | margin: 0 1px; 63 | } 64 | 65 | /* 小型表单控件 */ 66 | .form-control, .form-select { 67 | font-size: 0.875rem; 68 | padding: 0.25rem 0.5rem; 69 | } 70 | 71 | .form-check-input { 72 | width: 0.9rem; 73 | height: 0.9rem; 74 | margin-top: 0.25rem; 75 | } 76 | 77 | .badge { 78 | font-size: 75%; 79 | padding: 0.25em 0.5em; 80 | } 81 | 82 | /* 状态颜色 */ 83 | .status-pending { 84 | background-color: #6c757d; 85 | } 86 | 87 | .status-running { 88 | background-color: #17a2b8; 89 | } 90 | 91 | .status-success { 92 | background-color: #28a745; 93 | } 94 | 95 | .status-failed { 96 | background-color: #dc3545; 97 | } 98 | 99 | .status-cancelled { 100 | background-color: #ffc107; 101 | color: #212529; 102 | } 103 | 104 | .status-cleaned { 105 | background-color: #6610f2; 106 | } 107 | 108 | /* 列宽度设置 */ 109 | .table th:nth-child(1), /* ID列 */ 110 | .table td:nth-child(1) { 111 | min-width: 40px; 112 | width: 40px; 113 | } 114 | 115 | .table th:nth-child(2), /* 名称/任务列 */ 116 | .table td:nth-child(2) { 117 | min-width: 100px; 118 | } 119 | 120 | .table th:nth-child(3), /* 类型/开始时间列 */ 121 | .table td:nth-child(3) { 122 | min-width: 100px; 123 | } 124 | 125 | .table th:nth-child(4), /* 调度/结束时间列 */ 126 | .table td:nth-child(4) { 127 | min-width: 120px; 128 | } 129 | 130 | .table th:nth-child(5), /* 状态列 */ 131 | .table td:nth-child(5) { 132 | min-width: 100px; 133 | } 134 | 135 | /* 文件大小列 (仅备份记录表格) */ 136 | #records-panel .table th:nth-child(6), 137 | #records-panel .table td:nth-child(6) { 138 | min-width: 80px; 139 | } 140 | 141 | /* 操作列 */ 142 | .table th:last-child, 143 | .table td:last-child { 144 | min-width: 220px; 145 | white-space: nowrap; 146 | } 147 | 148 | /* 响应式调整 */ 149 | @media (max-width: 768px) { 150 | .table-responsive { 151 | overflow-x: auto; 152 | width: 100%; 153 | -webkit-overflow-scrolling: touch; 154 | } 155 | 156 | .btn-icon { 157 | padding: 0.15rem 0.3rem; 158 | font-size: 0.75rem; 159 | } 160 | 161 | /* 操作列在小屏幕上保持固定宽度 */ 162 | .table th:last-child, 163 | .table td:last-child { 164 | min-width: 200px; 165 | } 166 | 167 | .task-buttons-container, 168 | .record-buttons-container { 169 | gap: 2px; 170 | } 171 | } 172 | 173 | /* 超小屏幕额外优化 */ 174 | @media (max-width: 480px) { 175 | .btn-icon { 176 | padding: 0.1rem 0.25rem; 177 | font-size: 0.7rem; 178 | margin-right: 1px; 179 | } 180 | 181 | /* 在超小屏幕上调整ID列的宽度 */ 182 | .table th:nth-child(1), 183 | .table td:nth-child(1) { 184 | min-width: 30px; 185 | width: 30px; 186 | } 187 | 188 | .table th:last-child, 189 | .table td:last-child { 190 | min-width: 180px; 191 | } 192 | } 193 | 194 | /* 动画效果 */ 195 | .fade-in { 196 | animation: fadeIn 0.5s ease-in-out; 197 | } 198 | 199 | @keyframes fadeIn { 200 | from { opacity: 0; } 201 | to { opacity: 1; } 202 | } 203 | 204 | /* 卡片样式 */ 205 | .card { 206 | border-radius: 8px; 207 | box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1); 208 | margin-bottom: 20px; 209 | transition: transform 0.2s; 210 | } 211 | 212 | 213 | .card-header { 214 | border-radius: 8px 8px 0 0; 215 | background-color: #f8f9fa; 216 | } 217 | 218 | /* 模态框样式 */ 219 | .modal-content { 220 | border-radius: 8px; 221 | box-shadow: 0 5px 15px rgba(0, 0, 0, 0.2); 222 | } 223 | 224 | /* 任务和记录按钮容器样式 */ 225 | .task-buttons-container, 226 | .record-buttons-container { 227 | display: flex; 228 | flex-wrap: wrap; 229 | gap: 3px; 230 | } 231 | 232 | /* 任务启用开关样式 */ 233 | .form-check.form-switch { 234 | display: flex; 235 | align-items: center; 236 | justify-content: flex-start; 237 | margin: 0; 238 | /*padding: 0;*/ 239 | } 240 | 241 | .form-check.form-switch .form-check-input { 242 | margin-right: 0.5rem; 243 | cursor: pointer; 244 | position: relative; 245 | flex-shrink: 0; 246 | } 247 | 248 | .form-check.form-switch .form-check-label { 249 | margin-bottom: 0; 250 | display: flex; 251 | align-items: center; 252 | } 253 | 254 | /* 启用状态的开关 */ 255 | .form-check.form-switch .form-check-input:checked { 256 | background-color: #28a745; 257 | border-color: #28a745; 258 | } 259 | 260 | /* 禁用状态的开关 */ 261 | .form-check.form-switch .form-check-input:not(:checked) { 262 | background-color: #6c757d; 263 | border-color: #6c757d; 264 | } 265 | 266 | /* 启用状态背景 */ 267 | .task-enabled .form-check-input { 268 | background-color: #28a745 !important; 269 | border-color: #28a745 !important; 270 | } 271 | 272 | /* 禁用状态背景 */ 273 | .task-disabled .form-check-input { 274 | background-color: #6c757d !important; 275 | border-color: #6c757d !important; 276 | } 277 | 278 | /* 启用开关背景 */ 279 | .form-check.task-enabled .form-check-input { 280 | background-color: #28a745; 281 | border-color: #28a745; 282 | } 283 | 284 | /* 禁用开关背景 */ 285 | .form-check.task-disabled .form-check-input { 286 | background-color: #6c757d; 287 | border-color: #6c757d; 288 | } 289 | 290 | /* 移除之前的下拉菜单样式 */ 291 | .action-dropdown, 292 | .action-dropdown-toggle, 293 | .action-dropdown-menu, 294 | .action-dropdown-item { 295 | display: none !important; 296 | } 297 | .mt-6 { 298 | margin-top: 96px; 299 | } 300 | 301 | /* 加载动画容器 */ 302 | .loading-overlay { 303 | position: fixed; 304 | top: 0; 305 | left: 0; 306 | width: 100%; 307 | height: 100%; 308 | background-color: rgba(0, 0, 0, 0.5); 309 | display: none; 310 | align-items: center; 311 | justify-content: center; 312 | z-index: 9999; 313 | transition: opacity 0.4s ease; 314 | opacity: 1; 315 | } 316 | 317 | /* 加载动画 */ 318 | .loading-spinner { 319 | width: 2.5rem; 320 | height: 2.5rem; 321 | border: 0.25rem solid #f3f3f3; 322 | border-top: 0.25rem solid #3498db; 323 | border-radius: 50%; 324 | animation: spin 1s linear infinite; 325 | } 326 | 327 | /* 加载内容容器 */ 328 | .loading-content { 329 | display: flex; 330 | flex-direction: column; 331 | align-items: center; 332 | background-color: #ffffff; 333 | padding: 20px; 334 | border-radius: 10px; 335 | box-shadow: 0 5px 15px rgba(0, 0, 0, 0.2); 336 | transform: scale(1); 337 | transition: transform 0.3s ease; 338 | } 339 | 340 | /* 加载文本 */ 341 | .loading-text { 342 | margin-top: 15px; 343 | font-size: 1rem; 344 | color: #333; 345 | } 346 | 347 | /* 旋转动画 */ 348 | @keyframes spin { 349 | 0% { transform: rotate(0deg); } 350 | 100% { transform: rotate(360deg); } 351 | } 352 | 353 | /* 区域加载状态 */ 354 | .section-loading { 355 | position: relative; 356 | pointer-events: none; 357 | opacity: 0.7; 358 | } 359 | 360 | /* 区域加载指示器 */ 361 | .section-loading-indicator { 362 | position: absolute; 363 | top: 50%; 364 | left: 50%; 365 | transform: translate(-50%, -50%); 366 | z-index: 100; 367 | background-color: rgba(255, 255, 255, 0.9); 368 | border-radius: 10px; 369 | padding: 15px; 370 | box-shadow: 0 0 10px rgba(0, 0, 0, 0.1); 371 | display: flex; 372 | flex-direction: column; 373 | align-items: center; 374 | justify-content: center; 375 | opacity: 0; 376 | transition: opacity 0.3s ease; 377 | } 378 | 379 | /* 区域加载动画 */ 380 | .section-loading-spinner { 381 | width: 1.5rem; 382 | height: 1.5rem; 383 | border: 0.2rem solid #f3f3f3; 384 | border-top: 0.2rem solid #3498db; 385 | border-radius: 50%; 386 | animation: spin 1s linear infinite; 387 | } 388 | 389 | /* 当区域加载时,内容应该有轻微模糊效果 */ 390 | .section-loading > *:not(.section-loading-indicator) { 391 | filter: blur(1px); 392 | transition: filter 0.3s ease; 393 | } 394 | 395 | /* 表格行鼠标悬停效果 */ 396 | .table tr { 397 | transition: all 0.2s ease; 398 | } 399 | 400 | .table tr:hover { 401 | background-color: rgba(0, 123, 255, 0.05); 402 | box-shadow: 0 2px 5px rgba(0, 0, 0, 0.05); 403 | } 404 | 405 | /* 记录图标效果优化 */ 406 | .record-icon { 407 | width: 16px; 408 | height: 16px; 409 | vertical-align: middle; 410 | opacity: 0.8; 411 | transition: all 0.3s ease; 412 | filter: saturate(90%); 413 | } 414 | 415 | tr:hover .record-icon { 416 | opacity: 1; 417 | transform: scale(1.15) rotate(5deg); 418 | filter: saturate(120%) drop-shadow(0 1px 2px rgba(0,0,0,0.1)); 419 | } 420 | 421 | /* 导航栏logo样式 */ 422 | .navbar-logo { 423 | filter: brightness(0) invert(1); /* 将SVG转为白色 */ 424 | opacity: 0.9; 425 | transition: all 0.3s ease; 426 | } 427 | 428 | .navbar-brand:hover .navbar-logo { 429 | transform: rotate(15deg) scale(1.1); 430 | opacity: 1; 431 | } -------------------------------------------------------------------------------- /public/assets/fonts/bootstrap-icons.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zzzgr/backup-go/efd686f285a25cdfa0157b80c7a711169672df8b/public/assets/fonts/bootstrap-icons.woff -------------------------------------------------------------------------------- /public/assets/fonts/bootstrap-icons.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zzzgr/backup-go/efd686f285a25cdfa0157b80c7a711169672df8b/public/assets/fonts/bootstrap-icons.woff2 -------------------------------------------------------------------------------- /public/assets/img/logo.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /public/assets/img/logo2.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /public/assets/libs/bootstrap-icons.min.css: -------------------------------------------------------------------------------- 1 | @font-face {font-display: block;font-family: "bootstrap-icons";src: url("../fonts/bootstrap-icons.woff2") format("woff2"),url("../fonts/bootstrap-icons.woff") format("woff");}.bi::before,[class^="bi-"]::before,[class*=" bi-"]::before {display: inline-block;font-family: bootstrap-icons !important;font-style: normal;font-weight: normal !important;font-variant: normal;text-transform: none;line-height: 1;vertical-align: -.125em;-webkit-font-smoothing: antialiased;-moz-osx-font-smoothing: grayscale;}.bi-chevron-down::before {content: " 229";} 2 | -------------------------------------------------------------------------------- /public/assets/libs/cron-validator.min.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Minified by jsDelivr using Terser v5.19.2. 3 | * Original file: /npm/cron-validator@1.3.1/lib/index.js 4 | * 5 | * Do NOT use SRI with dynamically generated files! More information: https://www.jsdelivr.com/using-sri-with-dynamic-files 6 | */ 7 | (function(window) { 8 | var __assign=this&&this.__assign||function(){return __assign=Object.assign||function(a){for(var n,e=1,r=arguments.length;e=n&&a<=e},isValidRange=function(a,n,e){var r=a.split("-");switch(r.length){case 1:return isWildcard(a)||isInRange(safeParseInt(a),n,e);case 2:var t=r.map((function(a){return safeParseInt(a)})),s=t[0],i=t[1];return s<=i&&isInRange(s,n,e)&&isInRange(i,n,e);default:return!1}},isValidStep=function(a){return void 0===a||-1===a.search(/[^\d]/)&&safeParseInt(a)>0},validateForRange=function(a,n,e){return-1===a.search(/[^\d-,\/*]/)&&a.split(",").every((function(a){var r=a.split("/");if(a.trim().endsWith("/"))return!1;if(r.length>2)return!1;var t=r[0],s=r[1];return isValidRange(t,n,e)&&isValidStep(s)}))},hasValidSeconds=function(a){return validateForRange(a,0,59)},hasValidMinutes=function(a){return validateForRange(a,0,59)},hasValidHours=function(a){return validateForRange(a,0,23)},hasValidDays=function(a,n){return n&&isQuestionMark(a)||validateForRange(a,1,31)},monthAlias={jan:"1",feb:"2",mar:"3",apr:"4",may:"5",jun:"6",jul:"7",aug:"8",sep:"9",oct:"10",nov:"11",dec:"12"},hasValidMonths=function(a,n){if(-1!==a.search(/\/[a-zA-Z]/))return!1;if(n){var e=a.toLowerCase().replace(/[a-z]{3}/g,(function(a){return void 0===monthAlias[a]?a:monthAlias[a]}));return validateForRange(e,1,12)}return validateForRange(a,1,12)},weekdaysAlias={sun:"0",mon:"1",tue:"2",wed:"3",thu:"4",fri:"5",sat:"6"},hasValidWeekdays=function(a,n,e,r){if(e&&isQuestionMark(a))return!0;if(!e&&isQuestionMark(a))return!1;if(-1!==a.search(/\/[a-zA-Z]/))return!1;if(n){var t=a.toLowerCase().replace(/[a-z]{3}/g,(function(a){return void 0===weekdaysAlias[a]?a:weekdaysAlias[a]}));return validateForRange(t,0,r?7:6)}return validateForRange(a,0,r?7:6)},hasCompatibleDayFormat=function(a,n,e){return!(e&&isQuestionMark(a)&&isQuestionMark(n))},split=function(a){return a.trim().split(/\s+/)},defaultOptions={alias:!1,seconds:!1,allowBlankDay:!1,allowSevenAsSunday:!1}; 10 | 11 | window.cronValidator = { 12 | isValidCron: function(a,n){ 13 | n=__assign(__assign({},defaultOptions),n); 14 | var e=split(a); 15 | if(e.length>(n.seconds?6:5)||e.length<5)return!1; 16 | var r=[]; 17 | if(6===e.length){var t=e.shift();t&&r.push(hasValidSeconds(t))} 18 | var s=e[0],i=e[1],u=e[2],o=e[3],l=e[4]; 19 | return r.push(hasValidMinutes(s)),r.push(hasValidHours(i)),r.push(hasValidDays(u,n.allowBlankDay)),r.push(hasValidMonths(o,n.alias)),r.push(hasValidWeekdays(l,n.alias,n.allowBlankDay,n.allowSevenAsSunday)),r.push(hasCompatibleDayFormat(u,l,n.allowBlankDay)),r.every(Boolean); 20 | } 21 | }; 22 | //# sourceMappingURL=/sm/f71f99ab89daade0361173f495548faed0785b785611a5312534d38749fbf2a8.map 23 | })(window); -------------------------------------------------------------------------------- /public/assets/libs/sweetalert2.min.css: -------------------------------------------------------------------------------- 1 | :root{--swal2-container-padding: 0.625em;--swal2-backdrop: rgba(0, 0, 0, 0.4);--swal2-width: 32em;--swal2-padding: 0 0 1.25em;--swal2-border: none;--swal2-border-radius: 0.3125rem;--swal2-background: white;--swal2-color: #545454;--swal2-footer-border-color: #eee;--swal2-show-animation: swal2-show 0.3s;--swal2-hide-animation: swal2-hide 0.15s forwards;--swal2-input-background: transparent;--swal2-progress-step-background: #add8e6;--swal2-validation-message-background: #f0f0f0;--swal2-validation-message-color: #666;--swal2-close-button-position: initial;--swal2-close-button-inset: auto;--swal2-close-button-font-size: 2.5em;--swal2-close-button-color: #ccc}[data-swal2-theme=dark]{--swal2-dark-theme-black: #19191a;--swal2-dark-theme-white: #e1e1e1;--swal2-background: var(--swal2-dark-theme-black);--swal2-color: var(--swal2-dark-theme-white);--swal2-footer-border-color: #555;--swal2-input-background: color-mix(in srgb, var(--swal2-dark-theme-black), var(--swal2-dark-theme-white) 10%);--swal2-validation-message-background: color-mix( in srgb, var(--swal2-dark-theme-black), var(--swal2-dark-theme-white) 10% );--swal2-validation-message-color: var(--swal2-dark-theme-white)}@media(prefers-color-scheme: dark){[data-swal2-theme=auto]{--swal2-dark-theme-black: #19191a;--swal2-dark-theme-white: #e1e1e1;--swal2-background: var(--swal2-dark-theme-black);--swal2-color: var(--swal2-dark-theme-white);--swal2-footer-border-color: #555;--swal2-input-background: color-mix(in srgb, var(--swal2-dark-theme-black), var(--swal2-dark-theme-white) 10%);--swal2-validation-message-background: color-mix( in srgb, var(--swal2-dark-theme-black), var(--swal2-dark-theme-white) 10% );--swal2-validation-message-color: var(--swal2-dark-theme-white)}}body.swal2-shown:not(.swal2-no-backdrop,.swal2-toast-shown){overflow:hidden}body.swal2-height-auto{height:auto !important}body.swal2-no-backdrop .swal2-container{background-color:rgba(0,0,0,0) !important;pointer-events:none}body.swal2-no-backdrop .swal2-container .swal2-popup{pointer-events:all}body.swal2-no-backdrop .swal2-container .swal2-modal{box-shadow:0 0 10px var(--swal2-backdrop)}body.swal2-toast-shown .swal2-container{box-sizing:border-box;width:360px;max-width:100%;background-color:rgba(0,0,0,0);pointer-events:none}body.swal2-toast-shown .swal2-container.swal2-top{inset:0 auto auto 50%;transform:translateX(-50%)}body.swal2-toast-shown .swal2-container.swal2-top-end,body.swal2-toast-shown .swal2-container.swal2-top-right{inset:0 0 auto auto}body.swal2-toast-shown .swal2-container.swal2-top-start,body.swal2-toast-shown .swal2-container.swal2-top-left{inset:0 auto auto 0}body.swal2-toast-shown .swal2-container.swal2-center-start,body.swal2-toast-shown .swal2-container.swal2-center-left{inset:50% auto auto 0;transform:translateY(-50%)}body.swal2-toast-shown .swal2-container.swal2-center{inset:50% auto auto 50%;transform:translate(-50%, -50%)}body.swal2-toast-shown .swal2-container.swal2-center-end,body.swal2-toast-shown .swal2-container.swal2-center-right{inset:50% 0 auto auto;transform:translateY(-50%)}body.swal2-toast-shown .swal2-container.swal2-bottom-start,body.swal2-toast-shown .swal2-container.swal2-bottom-left{inset:auto auto 0 0}body.swal2-toast-shown .swal2-container.swal2-bottom{inset:auto auto 0 50%;transform:translateX(-50%)}body.swal2-toast-shown .swal2-container.swal2-bottom-end,body.swal2-toast-shown .swal2-container.swal2-bottom-right{inset:auto 0 0 auto}@media print{body.swal2-shown:not(.swal2-no-backdrop,.swal2-toast-shown){overflow-y:scroll !important}body.swal2-shown:not(.swal2-no-backdrop,.swal2-toast-shown)>[aria-hidden=true]{display:none}body.swal2-shown:not(.swal2-no-backdrop,.swal2-toast-shown) .swal2-container{position:static !important}}div:where(.swal2-container){display:grid;position:fixed;z-index:1060;inset:0;box-sizing:border-box;grid-template-areas:"top-start top top-end" "center-start center center-end" "bottom-start bottom-center bottom-end";grid-template-rows:minmax(min-content, auto) minmax(min-content, auto) minmax(min-content, auto);height:100%;padding:var(--swal2-container-padding);overflow-x:hidden;transition:background-color .1s;-webkit-overflow-scrolling:touch}div:where(.swal2-container).swal2-backdrop-show,div:where(.swal2-container).swal2-noanimation{background:var(--swal2-backdrop)}div:where(.swal2-container).swal2-backdrop-hide{background:rgba(0,0,0,0) !important}div:where(.swal2-container).swal2-top-start,div:where(.swal2-container).swal2-center-start,div:where(.swal2-container).swal2-bottom-start{grid-template-columns:minmax(0, 1fr) auto auto}div:where(.swal2-container).swal2-top,div:where(.swal2-container).swal2-center,div:where(.swal2-container).swal2-bottom{grid-template-columns:auto minmax(0, 1fr) auto}div:where(.swal2-container).swal2-top-end,div:where(.swal2-container).swal2-center-end,div:where(.swal2-container).swal2-bottom-end{grid-template-columns:auto auto minmax(0, 1fr)}div:where(.swal2-container).swal2-top-start>.swal2-popup{align-self:start}div:where(.swal2-container).swal2-top>.swal2-popup{grid-column:2;place-self:start center}div:where(.swal2-container).swal2-top-end>.swal2-popup,div:where(.swal2-container).swal2-top-right>.swal2-popup{grid-column:3;place-self:start end}div:where(.swal2-container).swal2-center-start>.swal2-popup,div:where(.swal2-container).swal2-center-left>.swal2-popup{grid-row:2;align-self:center}div:where(.swal2-container).swal2-center>.swal2-popup{grid-column:2;grid-row:2;place-self:center center}div:where(.swal2-container).swal2-center-end>.swal2-popup,div:where(.swal2-container).swal2-center-right>.swal2-popup{grid-column:3;grid-row:2;place-self:center end}div:where(.swal2-container).swal2-bottom-start>.swal2-popup,div:where(.swal2-container).swal2-bottom-left>.swal2-popup{grid-column:1;grid-row:3;align-self:end}div:where(.swal2-container).swal2-bottom>.swal2-popup{grid-column:2;grid-row:3;place-self:end center}div:where(.swal2-container).swal2-bottom-end>.swal2-popup,div:where(.swal2-container).swal2-bottom-right>.swal2-popup{grid-column:3;grid-row:3;place-self:end end}div:where(.swal2-container).swal2-grow-row>.swal2-popup,div:where(.swal2-container).swal2-grow-fullscreen>.swal2-popup{grid-column:1/4;width:100%}div:where(.swal2-container).swal2-grow-column>.swal2-popup,div:where(.swal2-container).swal2-grow-fullscreen>.swal2-popup{grid-row:1/4;align-self:stretch}div:where(.swal2-container).swal2-no-transition{transition:none !important}div:where(.swal2-container) div:where(.swal2-popup){display:none;position:relative;box-sizing:border-box;grid-template-columns:minmax(0, 100%);width:var(--swal2-width);max-width:100%;padding:var(--swal2-padding);border:var(--swal2-border);border-radius:var(--swal2-border-radius);background:var(--swal2-background);color:var(--swal2-color);font-family:inherit;font-size:1rem}div:where(.swal2-container) div:where(.swal2-popup):focus{outline:none}div:where(.swal2-container) div:where(.swal2-popup).swal2-loading{overflow-y:hidden}div:where(.swal2-container) div:where(.swal2-popup).swal2-draggable{cursor:grab}div:where(.swal2-container) div:where(.swal2-popup).swal2-draggable div:where(.swal2-icon){cursor:grab}div:where(.swal2-container) div:where(.swal2-popup).swal2-dragging{cursor:grabbing}div:where(.swal2-container) div:where(.swal2-popup).swal2-dragging div:where(.swal2-icon){cursor:grabbing}div:where(.swal2-container) h2:where(.swal2-title){position:relative;max-width:100%;margin:0;padding:.8em 1em 0;color:inherit;font-size:1.875em;font-weight:600;text-align:center;text-transform:none;word-wrap:break-word;cursor:initial}div:where(.swal2-container) div:where(.swal2-actions){display:flex;z-index:1;box-sizing:border-box;flex-wrap:wrap;align-items:center;justify-content:center;width:auto;margin:1.25em auto 0;padding:0}div:where(.swal2-container) div:where(.swal2-actions):not(.swal2-loading) .swal2-styled[disabled]{opacity:.4}div:where(.swal2-container) div:where(.swal2-actions):not(.swal2-loading) .swal2-styled:hover{background-image:linear-gradient(rgba(0, 0, 0, 0.1), rgba(0, 0, 0, 0.1))}div:where(.swal2-container) div:where(.swal2-actions):not(.swal2-loading) .swal2-styled:active{background-image:linear-gradient(rgba(0, 0, 0, 0.2), rgba(0, 0, 0, 0.2))}div:where(.swal2-container) div:where(.swal2-loader){display:none;align-items:center;justify-content:center;width:2.2em;height:2.2em;margin:0 1.875em;animation:swal2-rotate-loading 1.5s linear 0s infinite normal;border-width:.25em;border-style:solid;border-radius:100%;border-color:#2778c4 rgba(0,0,0,0) #2778c4 rgba(0,0,0,0)}div:where(.swal2-container) button:where(.swal2-styled){margin:.3125em;padding:.625em 1.1em;transition:box-shadow .1s;box-shadow:0 0 0 3px rgba(0,0,0,0);font-weight:500}div:where(.swal2-container) button:where(.swal2-styled):not([disabled]){cursor:pointer}div:where(.swal2-container) button:where(.swal2-styled):where(.swal2-confirm){border:0;border-radius:.25em;background:initial;background-color:#7066e0;color:#fff;font-size:1em}div:where(.swal2-container) button:where(.swal2-styled):where(.swal2-confirm):focus-visible{box-shadow:0 0 0 3px rgba(112,102,224,.5)}div:where(.swal2-container) button:where(.swal2-styled):where(.swal2-deny){border:0;border-radius:.25em;background:initial;background-color:#dc3741;color:#fff;font-size:1em}div:where(.swal2-container) button:where(.swal2-styled):where(.swal2-deny):focus-visible{box-shadow:0 0 0 3px rgba(220,55,65,.5)}div:where(.swal2-container) button:where(.swal2-styled):where(.swal2-cancel){border:0;border-radius:.25em;background:initial;background-color:#6e7881;color:#fff;font-size:1em}div:where(.swal2-container) button:where(.swal2-styled):where(.swal2-cancel):focus-visible{box-shadow:0 0 0 3px rgba(110,120,129,.5)}div:where(.swal2-container) button:where(.swal2-styled).swal2-default-outline:focus-visible{box-shadow:0 0 0 3px rgba(100,150,200,.5)}div:where(.swal2-container) button:where(.swal2-styled):focus-visible{outline:none}div:where(.swal2-container) button:where(.swal2-styled)::-moz-focus-inner{border:0}div:where(.swal2-container) div:where(.swal2-footer){margin:1em 0 0;padding:1em 1em 0;border-top:1px solid var(--swal2-footer-border-color);color:inherit;font-size:1em;text-align:center;cursor:initial}div:where(.swal2-container) .swal2-timer-progress-bar-container{position:absolute;right:0;bottom:0;left:0;grid-column:auto !important;overflow:hidden;border-bottom-right-radius:var(--swal2-border-radius);border-bottom-left-radius:var(--swal2-border-radius)}div:where(.swal2-container) div:where(.swal2-timer-progress-bar){width:100%;height:.25em;background:rgba(0,0,0,.2)}div:where(.swal2-container) img:where(.swal2-image){max-width:100%;margin:2em auto 1em;cursor:initial}div:where(.swal2-container) button:where(.swal2-close){position:var(--swal2-close-button-position);inset:var(--swal2-close-button-inset);z-index:2;align-items:center;justify-content:center;width:1.2em;height:1.2em;margin-top:0;margin-right:0;margin-bottom:-1.2em;padding:0;overflow:hidden;transition:color .1s,box-shadow .1s;border:none;border-radius:var(--swal2-border-radius);background:rgba(0,0,0,0);color:var(--swal2-close-button-color);font-family:monospace;font-size:var(--swal2-close-button-font-size);cursor:pointer;justify-self:end}div:where(.swal2-container) button:where(.swal2-close):hover{transform:none;background:rgba(0,0,0,0);color:#f27474}div:where(.swal2-container) button:where(.swal2-close):focus-visible{outline:none;box-shadow:inset 0 0 0 3px rgba(100,150,200,.5)}div:where(.swal2-container) button:where(.swal2-close)::-moz-focus-inner{border:0}div:where(.swal2-container) div:where(.swal2-html-container){z-index:1;justify-content:center;margin:0;padding:1em 1.6em .3em;overflow:auto;color:inherit;font-size:1.125em;font-weight:normal;line-height:normal;text-align:center;word-wrap:break-word;word-break:break-word;cursor:initial}div:where(.swal2-container) input:where(.swal2-input),div:where(.swal2-container) input:where(.swal2-file),div:where(.swal2-container) textarea:where(.swal2-textarea),div:where(.swal2-container) select:where(.swal2-select),div:where(.swal2-container) div:where(.swal2-radio),div:where(.swal2-container) label:where(.swal2-checkbox){margin:1em 2em 3px}div:where(.swal2-container) input:where(.swal2-input),div:where(.swal2-container) input:where(.swal2-file),div:where(.swal2-container) textarea:where(.swal2-textarea){box-sizing:border-box;width:auto;transition:border-color .1s,box-shadow .1s;border:1px solid #d9d9d9;border-radius:.1875em;background:var(--swal2-input-background);box-shadow:inset 0 1px 1px rgba(0,0,0,.06),0 0 0 3px rgba(0,0,0,0);color:inherit;font-size:1.125em}div:where(.swal2-container) input:where(.swal2-input).swal2-inputerror,div:where(.swal2-container) input:where(.swal2-file).swal2-inputerror,div:where(.swal2-container) textarea:where(.swal2-textarea).swal2-inputerror{border-color:#f27474 !important;box-shadow:0 0 2px #f27474 !important}div:where(.swal2-container) input:where(.swal2-input):focus,div:where(.swal2-container) input:where(.swal2-file):focus,div:where(.swal2-container) textarea:where(.swal2-textarea):focus{border:1px solid #b4dbed;outline:none;box-shadow:inset 0 1px 1px rgba(0,0,0,.06),0 0 0 3px rgba(100,150,200,.5)}div:where(.swal2-container) input:where(.swal2-input)::placeholder,div:where(.swal2-container) input:where(.swal2-file)::placeholder,div:where(.swal2-container) textarea:where(.swal2-textarea)::placeholder{color:#ccc}div:where(.swal2-container) .swal2-range{margin:1em 2em 3px;background:var(--swal2-background)}div:where(.swal2-container) .swal2-range input{width:80%}div:where(.swal2-container) .swal2-range output{width:20%;color:inherit;font-weight:600;text-align:center}div:where(.swal2-container) .swal2-range input,div:where(.swal2-container) .swal2-range output{height:2.625em;padding:0;font-size:1.125em;line-height:2.625em}div:where(.swal2-container) .swal2-input{height:2.625em;padding:0 .75em}div:where(.swal2-container) .swal2-file{width:75%;margin-right:auto;margin-left:auto;background:var(--swal2-input-background);font-size:1.125em}div:where(.swal2-container) .swal2-textarea{height:6.75em;padding:.75em}div:where(.swal2-container) .swal2-select{min-width:50%;max-width:100%;padding:.375em .625em;background:var(--swal2-input-background);color:inherit;font-size:1.125em}div:where(.swal2-container) .swal2-radio,div:where(.swal2-container) .swal2-checkbox{align-items:center;justify-content:center;background:var(--swal2-background);color:inherit}div:where(.swal2-container) .swal2-radio label,div:where(.swal2-container) .swal2-checkbox label{margin:0 .6em;font-size:1.125em}div:where(.swal2-container) .swal2-radio input,div:where(.swal2-container) .swal2-checkbox input{flex-shrink:0;margin:0 .4em}div:where(.swal2-container) label:where(.swal2-input-label){display:flex;justify-content:center;margin:1em auto 0}div:where(.swal2-container) div:where(.swal2-validation-message){align-items:center;justify-content:center;margin:1em 0 0;padding:.625em;overflow:hidden;background:var(--swal2-validation-message-background);color:var(--swal2-validation-message-color);font-size:1em;font-weight:300}div:where(.swal2-container) div:where(.swal2-validation-message)::before{content:"!";display:inline-block;width:1.5em;min-width:1.5em;height:1.5em;margin:0 .625em;border-radius:50%;background-color:#f27474;color:#fff;font-weight:600;line-height:1.5em;text-align:center}div:where(.swal2-container) .swal2-progress-steps{flex-wrap:wrap;align-items:center;max-width:100%;margin:1.25em auto;padding:0;background:rgba(0,0,0,0);font-weight:600}div:where(.swal2-container) .swal2-progress-steps li{display:inline-block;position:relative}div:where(.swal2-container) .swal2-progress-steps .swal2-progress-step{z-index:20;flex-shrink:0;width:2em;height:2em;border-radius:2em;background:#2778c4;color:#fff;line-height:2em;text-align:center}div:where(.swal2-container) .swal2-progress-steps .swal2-progress-step.swal2-active-progress-step{background:#2778c4}div:where(.swal2-container) .swal2-progress-steps .swal2-progress-step.swal2-active-progress-step~.swal2-progress-step{background:var(--swal2-progress-step-background);color:#fff}div:where(.swal2-container) .swal2-progress-steps .swal2-progress-step.swal2-active-progress-step~.swal2-progress-step-line{background:var(--swal2-progress-step-background)}div:where(.swal2-container) .swal2-progress-steps .swal2-progress-step-line{z-index:10;flex-shrink:0;width:2.5em;height:.4em;margin:0 -1px;background:#2778c4}div:where(.swal2-icon){position:relative;box-sizing:content-box;justify-content:center;width:5em;height:5em;margin:2.5em auto .6em;border:.25em solid rgba(0,0,0,0);border-radius:50%;border-color:#000;font-family:inherit;line-height:5em;cursor:default;user-select:none}div:where(.swal2-icon) .swal2-icon-content{display:flex;align-items:center;font-size:3.75em}div:where(.swal2-icon).swal2-error{border-color:#f27474;color:#f27474}div:where(.swal2-icon).swal2-error .swal2-x-mark{position:relative;flex-grow:1}div:where(.swal2-icon).swal2-error [class^=swal2-x-mark-line]{display:block;position:absolute;top:2.3125em;width:2.9375em;height:.3125em;border-radius:.125em;background-color:#f27474}div:where(.swal2-icon).swal2-error [class^=swal2-x-mark-line][class$=left]{left:1.0625em;transform:rotate(45deg)}div:where(.swal2-icon).swal2-error [class^=swal2-x-mark-line][class$=right]{right:1em;transform:rotate(-45deg)}div:where(.swal2-icon).swal2-error.swal2-icon-show{animation:swal2-animate-error-icon .5s}div:where(.swal2-icon).swal2-error.swal2-icon-show .swal2-x-mark{animation:swal2-animate-error-x-mark .5s}div:where(.swal2-icon).swal2-warning{border-color:#f8bb86;color:#f8bb86}div:where(.swal2-icon).swal2-warning.swal2-icon-show{animation:swal2-animate-error-icon .5s}div:where(.swal2-icon).swal2-warning.swal2-icon-show .swal2-icon-content{animation:swal2-animate-i-mark .5s}div:where(.swal2-icon).swal2-info{border-color:#3fc3ee;color:#3fc3ee}div:where(.swal2-icon).swal2-info.swal2-icon-show{animation:swal2-animate-error-icon .5s}div:where(.swal2-icon).swal2-info.swal2-icon-show .swal2-icon-content{animation:swal2-animate-i-mark .8s}div:where(.swal2-icon).swal2-question{border-color:#87adbd;color:#87adbd}div:where(.swal2-icon).swal2-question.swal2-icon-show{animation:swal2-animate-error-icon .5s}div:where(.swal2-icon).swal2-question.swal2-icon-show .swal2-icon-content{animation:swal2-animate-question-mark .8s}div:where(.swal2-icon).swal2-success{border-color:#a5dc86;color:#a5dc86}div:where(.swal2-icon).swal2-success [class^=swal2-success-circular-line]{position:absolute;width:3.75em;height:7.5em;border-radius:50%}div:where(.swal2-icon).swal2-success [class^=swal2-success-circular-line][class$=left]{top:-0.4375em;left:-2.0635em;transform:rotate(-45deg);transform-origin:3.75em 3.75em;border-radius:7.5em 0 0 7.5em}div:where(.swal2-icon).swal2-success [class^=swal2-success-circular-line][class$=right]{top:-0.6875em;left:1.875em;transform:rotate(-45deg);transform-origin:0 3.75em;border-radius:0 7.5em 7.5em 0}div:where(.swal2-icon).swal2-success .swal2-success-ring{position:absolute;z-index:2;top:-0.25em;left:-0.25em;box-sizing:content-box;width:100%;height:100%;border:.25em solid rgba(165,220,134,.3);border-radius:50%}div:where(.swal2-icon).swal2-success .swal2-success-fix{position:absolute;z-index:1;top:.5em;left:1.625em;width:.4375em;height:5.625em;transform:rotate(-45deg)}div:where(.swal2-icon).swal2-success [class^=swal2-success-line]{display:block;position:absolute;z-index:2;height:.3125em;border-radius:.125em;background-color:#a5dc86}div:where(.swal2-icon).swal2-success [class^=swal2-success-line][class$=tip]{top:2.875em;left:.8125em;width:1.5625em;transform:rotate(45deg)}div:where(.swal2-icon).swal2-success [class^=swal2-success-line][class$=long]{top:2.375em;right:.5em;width:2.9375em;transform:rotate(-45deg)}div:where(.swal2-icon).swal2-success.swal2-icon-show .swal2-success-line-tip{animation:swal2-animate-success-line-tip .75s}div:where(.swal2-icon).swal2-success.swal2-icon-show .swal2-success-line-long{animation:swal2-animate-success-line-long .75s}div:where(.swal2-icon).swal2-success.swal2-icon-show .swal2-success-circular-line-right{animation:swal2-rotate-success-circular-line 4.25s ease-in}[class^=swal2]{-webkit-tap-highlight-color:rgba(0,0,0,0)}.swal2-show{animation:var(--swal2-show-animation)}.swal2-hide{animation:var(--swal2-hide-animation)}.swal2-noanimation{transition:none}.swal2-scrollbar-measure{position:absolute;top:-9999px;width:50px;height:50px;overflow:scroll}.swal2-rtl .swal2-close{margin-right:initial;margin-left:0}.swal2-rtl .swal2-timer-progress-bar{right:0;left:auto}.swal2-toast{box-sizing:border-box;grid-column:1/4 !important;grid-row:1/4 !important;grid-template-columns:min-content auto min-content;padding:1em;overflow-y:hidden;background:var(--swal2-background);box-shadow:0 0 1px rgba(0,0,0,.075),0 1px 2px rgba(0,0,0,.075),1px 2px 4px rgba(0,0,0,.075),1px 3px 8px rgba(0,0,0,.075),2px 4px 16px rgba(0,0,0,.075);pointer-events:all}.swal2-toast>*{grid-column:2}.swal2-toast h2:where(.swal2-title){margin:.5em 1em;padding:0;font-size:1em;text-align:initial}.swal2-toast .swal2-loading{justify-content:center}.swal2-toast input:where(.swal2-input){height:2em;margin:.5em;font-size:1em}.swal2-toast .swal2-validation-message{font-size:1em}.swal2-toast div:where(.swal2-footer){margin:.5em 0 0;padding:.5em 0 0;font-size:.8em}.swal2-toast button:where(.swal2-close){grid-column:3/3;grid-row:1/99;align-self:center;width:.8em;height:.8em;margin:0;font-size:2em}.swal2-toast div:where(.swal2-html-container){margin:.5em 1em;padding:0;overflow:initial;font-size:1em;text-align:initial}.swal2-toast div:where(.swal2-html-container):empty{padding:0}.swal2-toast .swal2-loader{grid-column:1;grid-row:1/99;align-self:center;width:2em;height:2em;margin:.25em}.swal2-toast .swal2-icon{grid-column:1;grid-row:1/99;align-self:center;width:2em;min-width:2em;height:2em;margin:0 .5em 0 0}.swal2-toast .swal2-icon .swal2-icon-content{display:flex;align-items:center;font-size:1.8em;font-weight:bold}.swal2-toast .swal2-icon.swal2-success .swal2-success-ring{width:2em;height:2em}.swal2-toast .swal2-icon.swal2-error [class^=swal2-x-mark-line]{top:.875em;width:1.375em}.swal2-toast .swal2-icon.swal2-error [class^=swal2-x-mark-line][class$=left]{left:.3125em}.swal2-toast .swal2-icon.swal2-error [class^=swal2-x-mark-line][class$=right]{right:.3125em}.swal2-toast div:where(.swal2-actions){justify-content:flex-start;height:auto;margin:0;margin-top:.5em;padding:0 .5em}.swal2-toast button:where(.swal2-styled){margin:.25em .5em;padding:.4em .6em;font-size:1em}.swal2-toast .swal2-success{border-color:#a5dc86}.swal2-toast .swal2-success [class^=swal2-success-circular-line]{position:absolute;width:1.6em;height:3em;border-radius:50%}.swal2-toast .swal2-success [class^=swal2-success-circular-line][class$=left]{top:-0.8em;left:-0.5em;transform:rotate(-45deg);transform-origin:2em 2em;border-radius:4em 0 0 4em}.swal2-toast .swal2-success [class^=swal2-success-circular-line][class$=right]{top:-0.25em;left:.9375em;transform-origin:0 1.5em;border-radius:0 4em 4em 0}.swal2-toast .swal2-success .swal2-success-ring{width:2em;height:2em}.swal2-toast .swal2-success .swal2-success-fix{top:0;left:.4375em;width:.4375em;height:2.6875em}.swal2-toast .swal2-success [class^=swal2-success-line]{height:.3125em}.swal2-toast .swal2-success [class^=swal2-success-line][class$=tip]{top:1.125em;left:.1875em;width:.75em}.swal2-toast .swal2-success [class^=swal2-success-line][class$=long]{top:.9375em;right:.1875em;width:1.375em}.swal2-toast .swal2-success.swal2-icon-show .swal2-success-line-tip{animation:swal2-toast-animate-success-line-tip .75s}.swal2-toast .swal2-success.swal2-icon-show .swal2-success-line-long{animation:swal2-toast-animate-success-line-long .75s}.swal2-toast.swal2-show{animation:swal2-toast-show .5s}.swal2-toast.swal2-hide{animation:swal2-toast-hide .1s forwards}@keyframes swal2-show{0%{transform:scale(0.7)}45%{transform:scale(1.05)}80%{transform:scale(0.95)}100%{transform:scale(1)}}@keyframes swal2-hide{0%{transform:scale(1);opacity:1}100%{transform:scale(0.5);opacity:0}}@keyframes swal2-animate-success-line-tip{0%{top:1.1875em;left:.0625em;width:0}54%{top:1.0625em;left:.125em;width:0}70%{top:2.1875em;left:-0.375em;width:3.125em}84%{top:3em;left:1.3125em;width:1.0625em}100%{top:2.8125em;left:.8125em;width:1.5625em}}@keyframes swal2-animate-success-line-long{0%{top:3.375em;right:2.875em;width:0}65%{top:3.375em;right:2.875em;width:0}84%{top:2.1875em;right:0;width:3.4375em}100%{top:2.375em;right:.5em;width:2.9375em}}@keyframes swal2-rotate-success-circular-line{0%{transform:rotate(-45deg)}5%{transform:rotate(-45deg)}12%{transform:rotate(-405deg)}100%{transform:rotate(-405deg)}}@keyframes swal2-animate-error-x-mark{0%{margin-top:1.625em;transform:scale(0.4);opacity:0}50%{margin-top:1.625em;transform:scale(0.4);opacity:0}80%{margin-top:-0.375em;transform:scale(1.15)}100%{margin-top:0;transform:scale(1);opacity:1}}@keyframes swal2-animate-error-icon{0%{transform:rotateX(100deg);opacity:0}100%{transform:rotateX(0deg);opacity:1}}@keyframes swal2-rotate-loading{0%{transform:rotate(0deg)}100%{transform:rotate(360deg)}}@keyframes swal2-animate-question-mark{0%{transform:rotateY(-360deg)}100%{transform:rotateY(0)}}@keyframes swal2-animate-i-mark{0%{transform:rotateZ(45deg);opacity:0}25%{transform:rotateZ(-25deg);opacity:.4}50%{transform:rotateZ(15deg);opacity:.8}75%{transform:rotateZ(-5deg);opacity:1}100%{transform:rotateX(0);opacity:1}}@keyframes swal2-toast-show{0%{transform:translateY(-0.625em) rotateZ(2deg)}33%{transform:translateY(0) rotateZ(-2deg)}66%{transform:translateY(0.3125em) rotateZ(2deg)}100%{transform:translateY(0) rotateZ(0deg)}}@keyframes swal2-toast-hide{100%{transform:rotateZ(1deg);opacity:0}}@keyframes swal2-toast-animate-success-line-tip{0%{top:.5625em;left:.0625em;width:0}54%{top:.125em;left:.125em;width:0}70%{top:.625em;left:-0.25em;width:1.625em}84%{top:1.0625em;left:.75em;width:.5em}100%{top:1.125em;left:.1875em;width:.75em}}@keyframes swal2-toast-animate-success-line-long{0%{top:1.625em;right:1.375em;width:0}65%{top:1.25em;right:.9375em;width:0}84%{top:.9375em;right:0;width:1.125em}100%{top:.9375em;right:.1875em;width:1.375em}} 2 | -------------------------------------------------------------------------------- /public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 备份系统 10 | 11 | 12 | 13 | 14 | 15 | 16 | 40 | 41 |
42 | 43 |
44 |
45 |

任务管理

46 | 47 |
48 | 49 |
50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | 65 |
ID名称类型调度下次执行状态操作
66 |
67 | 72 |
73 | 74 | 75 | 111 | 112 | 113 | 289 |
290 | 291 | 292 | 300 | 301 | 302 | 398 | 399 | 400 | 433 | 434 | 435 | 451 | 452 | 453 | 471 | 472 | 473 |
475 | 476 |
477 | 478 | 479 | 485 | 486 | 487 | 488 | 489 | 490 | 491 | -------------------------------------------------------------------------------- /public/login.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 备份系统 - 登录 7 | 8 | 9 | 10 | 11 | 59 | 60 | 61 |
62 | 78 |
79 | 80 | 81 | 89 | 90 | 91 | 92 | 233 | 234 | -------------------------------------------------------------------------------- /repository/backup_record.go: -------------------------------------------------------------------------------- 1 | package repository 2 | 3 | import ( 4 | "backup-go/entity" 5 | "errors" 6 | "fmt" 7 | "time" 8 | ) 9 | 10 | // BackupRecordRepository 备份记录仓库 11 | type BackupRecordRepository struct { 12 | db interface{} // 使用空接口类型 13 | } 14 | 15 | // NewBackupRecordRepository 创建备份记录仓库 16 | func NewBackupRecordRepository() *BackupRecordRepository { 17 | return &BackupRecordRepository{ 18 | db: GetDB(), 19 | } 20 | } 21 | 22 | // Create 创建备份记录 23 | func (r *BackupRecordRepository) Create(record *entity.BackupRecord) error { 24 | // 确保时间戳字段正确设置 25 | now := time.Now() 26 | if record.CreatedAt.IsZero() { 27 | record.CreatedAt = now 28 | } 29 | if record.UpdatedAt.IsZero() { 30 | record.UpdatedAt = now 31 | } 32 | 33 | // 开始事务 34 | tx := GetDB().Begin() 35 | if tx.Error != nil { 36 | return tx.Error 37 | } 38 | 39 | // 在事务中执行创建操作 40 | if err := tx.Create(record).Error; err != nil { 41 | tx.Rollback() // 发生错误时回滚 42 | return err 43 | } 44 | 45 | // 提交事务 46 | return tx.Commit().Error 47 | } 48 | 49 | // Update 更新备份记录 50 | func (r *BackupRecordRepository) Update(record *entity.BackupRecord) error { 51 | // 更新UpdatedAt字段 52 | record.UpdatedAt = time.Now() 53 | 54 | // 开始事务 55 | tx := GetDB().Begin() 56 | if tx.Error != nil { 57 | return tx.Error 58 | } 59 | 60 | // 在事务中执行更新操作 61 | if err := tx.Model(record).Updates(record).Error; err != nil { 62 | tx.Rollback() // 发生错误时回滚 63 | return err 64 | } 65 | 66 | // 提交事务 67 | return tx.Commit().Error 68 | } 69 | 70 | // FindByID 根据ID查找备份记录 71 | func (r *BackupRecordRepository) FindByID(id int64) (*entity.BackupRecord, error) { 72 | var record entity.BackupRecord 73 | result := GetDB().First(&record, id) 74 | 75 | if result.Error != nil { 76 | if errors.Is(result.Error, errors.New("record not found")) { 77 | return nil, nil 78 | } 79 | return nil, result.Error 80 | } 81 | 82 | return &record, nil 83 | } 84 | 85 | // FindByTaskID 根据任务ID查找备份记录 86 | func (r *BackupRecordRepository) FindByTaskID(taskID int64) ([]*entity.BackupRecord, error) { 87 | var records []*entity.BackupRecord 88 | 89 | result := GetDB().Where("task_id = ?", taskID).Order("start_time desc").Find(&records) 90 | if result.Error != nil { 91 | return nil, result.Error 92 | } 93 | 94 | return records, nil 95 | } 96 | 97 | // FindLatestByTaskID 获取任务最新的备份记录 98 | func (r *BackupRecordRepository) FindLatestByTaskID(taskID int64) (*entity.BackupRecord, error) { 99 | var record entity.BackupRecord 100 | 101 | result := GetDB().Where("task_id = ?", taskID).Order("start_time desc").First(&record) 102 | if result.Error != nil { 103 | if errors.Is(result.Error, errors.New("record not found")) { 104 | return nil, nil 105 | } 106 | return nil, result.Error 107 | } 108 | 109 | return &record, nil 110 | } 111 | 112 | // FindAll 查询所有备份记录,支持分页 113 | func (r *BackupRecordRepository) FindAll(page, pageSize int) ([]*entity.BackupRecord, error) { 114 | var records []*entity.BackupRecord 115 | 116 | offset := (page - 1) * pageSize 117 | 118 | result := GetDB().Order("start_time desc").Offset(offset).Limit(pageSize).Find(&records) 119 | if result.Error != nil { 120 | return nil, result.Error 121 | } 122 | 123 | return records, nil 124 | } 125 | 126 | // CountAll 统计所有备份记录数量 127 | func (r *BackupRecordRepository) CountAll() (int64, error) { 128 | var count int64 129 | 130 | result := GetDB().Model(&entity.BackupRecord{}).Count(&count) 131 | if result.Error != nil { 132 | return 0, result.Error 133 | } 134 | 135 | return count, nil 136 | } 137 | 138 | // FindByStatus 根据状态查找备份记录 139 | func (r *BackupRecordRepository) FindByStatus(status entity.BackupStatus) ([]*entity.BackupRecord, error) { 140 | var records []*entity.BackupRecord 141 | 142 | result := GetDB().Where("status = ?", status).Find(&records) 143 | if result.Error != nil { 144 | return nil, result.Error 145 | } 146 | 147 | return records, nil 148 | } 149 | 150 | // Delete 删除备份记录 151 | func (r *BackupRecordRepository) Delete(id int64) error { 152 | // 开始事务 153 | tx := GetDB().Begin() 154 | if tx.Error != nil { 155 | return tx.Error 156 | } 157 | 158 | // 在事务中执行删除操作 159 | result := tx.Delete(&entity.BackupRecord{}, id) 160 | if result.Error != nil { 161 | tx.Rollback() // 发生错误时回滚 162 | return result.Error 163 | } 164 | 165 | // 提交事务 166 | return tx.Commit().Error 167 | } 168 | 169 | // DeleteByTaskID 根据任务ID删除所有相关备份记录 170 | func (r *BackupRecordRepository) DeleteByTaskID(taskID int64) error { 171 | // 开始事务 172 | tx := GetDB().Begin() 173 | if tx.Error != nil { 174 | return tx.Error 175 | } 176 | 177 | // 在事务中执行删除操作 178 | result := tx.Where("task_id = ?", taskID).Delete(&entity.BackupRecord{}) 179 | if result.Error != nil { 180 | tx.Rollback() // 发生错误时回滚 181 | return result.Error 182 | } 183 | 184 | // 提交事务 185 | return tx.Commit().Error 186 | } 187 | 188 | // FindByTaskIDPaginated 分页查询指定任务的备份记录 189 | func (r *BackupRecordRepository) FindByTaskIDPaginated(taskID int64, page, pageSize int) ([]*entity.BackupRecord, error) { 190 | var records []*entity.BackupRecord 191 | 192 | offset := (page - 1) * pageSize 193 | 194 | result := GetDB().Where("task_id = ?", taskID). 195 | Order("start_time desc"). 196 | Offset(offset). 197 | Limit(pageSize). 198 | Find(&records) 199 | 200 | if result.Error != nil { 201 | return nil, result.Error 202 | } 203 | 204 | return records, nil 205 | } 206 | 207 | // CountByTaskID 计算指定任务的备份记录总数 208 | func (r *BackupRecordRepository) CountByTaskID(taskID int64) (int64, error) { 209 | var count int64 210 | 211 | result := GetDB().Model(&entity.BackupRecord{}). 212 | Where("task_id = ?", taskID). 213 | Count(&count) 214 | 215 | if result.Error != nil { 216 | return 0, result.Error 217 | } 218 | 219 | return count, nil 220 | } 221 | 222 | // FindOlderThan 查找早于指定日期的记录 223 | func (r *BackupRecordRepository) FindOlderThan(date time.Time) ([]*entity.BackupRecord, error) { 224 | var records []*entity.BackupRecord 225 | 226 | // 使用时间条件查询记录 227 | result := GetDB().Where("start_time <= ?", date). 228 | Where("file_path != ?", ""). // 只查找有文件路径的记录 229 | Where("status != ?", entity.StatusCleaned). // 排除已清理的记录 230 | Find(&records) 231 | 232 | if result.Error != nil { 233 | return nil, fmt.Errorf("查询早于%s的记录失败: %w", date.Format("2006-01-02"), result.Error) 234 | } 235 | 236 | return records, nil 237 | } 238 | -------------------------------------------------------------------------------- /repository/backup_task.go: -------------------------------------------------------------------------------- 1 | package repository 2 | 3 | import ( 4 | "backup-go/entity" 5 | "encoding/json" 6 | "errors" 7 | "time" 8 | ) 9 | 10 | // BackupTaskRepository 备份任务仓库 11 | type BackupTaskRepository struct { 12 | db interface{} // 使用空接口类型 13 | } 14 | 15 | // NewBackupTaskRepository 创建备份任务仓库 16 | func NewBackupTaskRepository() *BackupTaskRepository { 17 | return &BackupTaskRepository{ 18 | db: GetDB(), 19 | } 20 | } 21 | 22 | // Create 创建备份任务 23 | func (r *BackupTaskRepository) Create(task *entity.BackupTask) error { 24 | // 确保时间戳字段正确设置 25 | now := time.Now() 26 | if task.CreatedAt.IsZero() { 27 | task.CreatedAt = now 28 | } 29 | if task.UpdatedAt.IsZero() { 30 | task.UpdatedAt = now 31 | } 32 | 33 | // 开始事务 34 | tx := GetDB().Begin() 35 | if tx.Error != nil { 36 | return tx.Error 37 | } 38 | 39 | // 在事务中执行创建操作 40 | if err := tx.Create(task).Error; err != nil { 41 | tx.Rollback() // 发生错误时回滚 42 | return err 43 | } 44 | 45 | // 提交事务 46 | return tx.Commit().Error 47 | } 48 | 49 | // Update 更新备份任务 50 | func (r *BackupTaskRepository) Update(task *entity.BackupTask) error { 51 | // 更新UpdatedAt字段 52 | task.UpdatedAt = time.Now() 53 | 54 | // 开始事务 55 | tx := GetDB().Begin() 56 | if tx.Error != nil { 57 | return tx.Error 58 | } 59 | 60 | // 使用Map明确列出要更新的字段,确保零值也会被更新 61 | updateMap := map[string]interface{}{ 62 | "name": task.Name, 63 | "type": task.Type, 64 | "source_info": task.SourceInfo, 65 | "schedule": task.Schedule, 66 | "enabled": task.Enabled, // 明确包含enabled字段 67 | "updated_at": task.UpdatedAt, 68 | } 69 | 70 | // 在事务中执行更新操作 71 | if err := tx.Model(task).Where("id = ?", task.ID).Updates(updateMap).Error; err != nil { 72 | tx.Rollback() // 发生错误时回滚 73 | return err 74 | } 75 | 76 | // 提交事务 77 | return tx.Commit().Error 78 | } 79 | 80 | // Delete 删除备份任务 81 | func (r *BackupTaskRepository) Delete(id int64) error { 82 | // 开始事务 83 | tx := GetDB().Begin() 84 | if tx.Error != nil { 85 | return tx.Error 86 | } 87 | 88 | // 在事务中执行删除操作 89 | result := tx.Delete(&entity.BackupTask{}, id) 90 | if result.Error != nil { 91 | tx.Rollback() // 发生错误时回滚 92 | return result.Error 93 | } 94 | 95 | // 提交事务 96 | return tx.Commit().Error 97 | } 98 | 99 | // FindByID 根据ID查找备份任务 100 | func (r *BackupTaskRepository) FindByID(id int64) (*entity.BackupTask, error) { 101 | var task entity.BackupTask 102 | result := GetDB().First(&task, id) 103 | 104 | if result.Error != nil { 105 | if errors.Is(result.Error, errors.New("record not found")) { 106 | return nil, nil 107 | } 108 | return nil, result.Error 109 | } 110 | 111 | return &task, nil 112 | } 113 | 114 | // FindAll 查找所有备份任务 115 | func (r *BackupTaskRepository) FindAll() ([]*entity.BackupTask, error) { 116 | var tasks []*entity.BackupTask 117 | 118 | result := GetDB().Order("id desc").Find(&tasks) 119 | if result.Error != nil { 120 | return nil, result.Error 121 | } 122 | 123 | return tasks, nil 124 | } 125 | 126 | // GetEnabledTasks 获取所有启用的任务 127 | func (r *BackupTaskRepository) GetEnabledTasks() ([]*entity.BackupTask, error) { 128 | var tasks []*entity.BackupTask 129 | 130 | result := GetDB().Where("enabled = ?", true).Order("id desc").Find(&tasks) 131 | if result.Error != nil { 132 | return nil, result.Error 133 | } 134 | 135 | return tasks, nil 136 | } 137 | 138 | // ParseDatabaseSourceInfo 解析数据库源信息 139 | func (r *BackupTaskRepository) ParseDatabaseSourceInfo(task *entity.BackupTask) (*entity.DatabaseSourceInfo, error) { 140 | if task.Type != entity.DatabaseBackup { 141 | return nil, errors.New("task is not a database backup") 142 | } 143 | 144 | var info entity.DatabaseSourceInfo 145 | err := json.Unmarshal([]byte(task.SourceInfo), &info) 146 | if err != nil { 147 | return nil, err 148 | } 149 | 150 | return &info, nil 151 | } 152 | 153 | // ParseFileSourceInfo 解析文件源信息 154 | func (r *BackupTaskRepository) ParseFileSourceInfo(task *entity.BackupTask) (*entity.FileSourceInfo, error) { 155 | if task.Type != entity.FileBackup { 156 | return nil, errors.New("task is not a file backup") 157 | } 158 | 159 | var info entity.FileSourceInfo 160 | err := json.Unmarshal([]byte(task.SourceInfo), &info) 161 | if err != nil { 162 | return nil, err 163 | } 164 | 165 | return &info, nil 166 | } 167 | 168 | // FindAllPaginated 分页查询所有备份任务 169 | func (r *BackupTaskRepository) FindAllPaginated(page, pageSize int) ([]*entity.BackupTask, error) { 170 | var tasks []*entity.BackupTask 171 | 172 | offset := (page - 1) * pageSize 173 | 174 | result := GetDB().Order("id desc"). 175 | Offset(offset). 176 | Limit(pageSize). 177 | Find(&tasks) 178 | 179 | if result.Error != nil { 180 | return nil, result.Error 181 | } 182 | 183 | return tasks, nil 184 | } 185 | 186 | // CountAll 计算备份任务总数 187 | func (r *BackupTaskRepository) CountAll() (int64, error) { 188 | var count int64 189 | 190 | result := GetDB().Model(&entity.BackupTask{}). 191 | Count(&count) 192 | 193 | if result.Error != nil { 194 | return 0, result.Error 195 | } 196 | 197 | return count, nil 198 | } 199 | -------------------------------------------------------------------------------- /repository/config_repository.go: -------------------------------------------------------------------------------- 1 | package repository 2 | 3 | import ( 4 | "backup-go/config" 5 | "backup-go/entity" 6 | "errors" 7 | "gorm.io/gorm" 8 | ) 9 | 10 | // ConfigRepository 配置仓库 11 | type ConfigRepository struct { 12 | db *gorm.DB 13 | } 14 | 15 | // NewConfigRepository 创建仓库实例 16 | func NewConfigRepository() *ConfigRepository { 17 | return &ConfigRepository{ 18 | db: config.GetDB(), 19 | } 20 | } 21 | 22 | // FindAll 查询所有配置 23 | func (r *ConfigRepository) FindAll() ([]*entity.SystemConfig, error) { 24 | var configs []*entity.SystemConfig 25 | if err := r.db.Find(&configs).Error; err != nil { 26 | return nil, err 27 | } 28 | return configs, nil 29 | } 30 | 31 | // FindByID 根据ID查询配置 32 | func (r *ConfigRepository) FindByID(id uint) (*entity.SystemConfig, error) { 33 | var config entity.SystemConfig 34 | if err := r.db.Where("id = ?", id).First(&config).Error; err != nil { 35 | if errors.Is(err, gorm.ErrRecordNotFound) { 36 | return nil, errors.New("配置不存在") 37 | } 38 | return nil, err 39 | } 40 | return &config, nil 41 | } 42 | 43 | // FindByKey 根据键查询配置 44 | func (r *ConfigRepository) FindByKey(key string) (*entity.SystemConfig, error) { 45 | var config entity.SystemConfig 46 | if err := r.db.Where("config_key = ?", key).First(&config).Error; err != nil { 47 | if errors.Is(err, gorm.ErrRecordNotFound) { 48 | return nil, errors.New("配置不存在") 49 | } 50 | return nil, err 51 | } 52 | return &config, nil 53 | } 54 | 55 | // Create 创建配置 56 | func (r *ConfigRepository) Create(config *entity.SystemConfig) error { 57 | return r.db.Create(config).Error 58 | } 59 | 60 | // Update 更新配置 61 | func (r *ConfigRepository) Update(config *entity.SystemConfig) error { 62 | return r.db.Save(config).Error 63 | } 64 | 65 | // Delete 删除配置 66 | func (r *ConfigRepository) Delete(id uint) error { 67 | return r.db.Delete(&entity.SystemConfig{}, id).Error 68 | } 69 | -------------------------------------------------------------------------------- /repository/database.go: -------------------------------------------------------------------------------- 1 | package repository 2 | 3 | import ( 4 | "backup-go/config" 5 | "gorm.io/gorm" 6 | ) 7 | 8 | // GetDB 获取GORM数据库连接 9 | func GetDB() *gorm.DB { 10 | // 直接使用配置模块的数据库连接 11 | return config.GetDB() 12 | } 13 | 14 | // 为兼容原有代码,提供获取*sql.DB的方法 15 | func GetSqlDB() (*gorm.DB, error) { 16 | return GetDB(), nil 17 | } 18 | -------------------------------------------------------------------------------- /service/backup/backup.go: -------------------------------------------------------------------------------- 1 | package backup 2 | 3 | import ( 4 | "backup-go/entity" 5 | ) 6 | 7 | // BackupService 备份服务接口 8 | type BackupService interface { 9 | // Execute 执行备份 10 | Execute(task *entity.BackupTask) (*entity.BackupRecord, error) 11 | 12 | // GetBackupType 获取备份类型 13 | GetBackupType() entity.BackupType 14 | } 15 | 16 | // 创建备份服务 17 | func NewBackupService(backupType entity.BackupType) (BackupService, error) { 18 | switch backupType { 19 | case entity.DatabaseBackup: 20 | return NewDatabaseBackupService(), nil 21 | case entity.FileBackup: 22 | return NewFileBackupService(), nil 23 | default: 24 | return nil, nil 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /service/backup/backup_init.go: -------------------------------------------------------------------------------- 1 | package backup 2 | 3 | import ( 4 | "backup-go/entity" 5 | "backup-go/repository" 6 | "fmt" 7 | "log" 8 | "time" 9 | ) 10 | 11 | // 处理结果统计 12 | type processStats struct { 13 | runningCount int // 处理的运行中记录数 14 | pendingCount int // 处理的等待中记录数 15 | processFailed int // 处理失败的记录数 16 | processSuccess int // 处理成功的记录数 17 | } 18 | 19 | // InitBackupRecords 处理系统启动时处于异常状态的备份记录 20 | // 包括"运行中"和"等待中"但超过一定时间的记录 21 | func InitBackupRecords() error { 22 | stats := &processStats{} 23 | 24 | // 处理运行中的记录 25 | if err := initRunningRecords(stats); err != nil { 26 | return err 27 | } 28 | 29 | // 处理长时间等待中的记录 30 | if err := initPendingRecords(stats); err != nil { 31 | return err 32 | } 33 | 34 | // 输出统计信息 35 | log.Printf("备份记录处理统计: 总共处理 %d 条记录 (运行中: %d, 等待中: %d), 成功: %d, 失败: %d", 36 | stats.runningCount+stats.pendingCount, 37 | stats.runningCount, 38 | stats.pendingCount, 39 | stats.processSuccess, 40 | stats.processFailed) 41 | 42 | return nil 43 | } 44 | 45 | // initRunningRecords 处理系统启动时处于"运行中"状态的备份记录 46 | // 这些记录可能是由于系统非正常退出导致的,需要将它们标记为失败 47 | func initRunningRecords(stats *processStats) error { 48 | recordRepo := repository.NewBackupRecordRepository() 49 | 50 | // 查找所有处于"运行中"状态的记录 51 | runningRecords, err := recordRepo.FindByStatus(entity.StatusRunning) 52 | if err != nil { 53 | return fmt.Errorf("查询运行中的备份记录失败: %w", err) 54 | } 55 | 56 | stats.runningCount = len(runningRecords) 57 | log.Printf("找到 %d 条处于运行中状态的备份记录,将标记为失败", stats.runningCount) 58 | 59 | // 处理每一条记录 60 | for _, record := range runningRecords { 61 | // 设置记录状态为失败 62 | record.Status = entity.StatusFailed 63 | record.EndTime = time.Now() 64 | record.ErrorMessage = "系统重启时,该任务处于运行中状态,已被自动标记为失败" 65 | 66 | // 更新记录 67 | if err := recordRepo.Update(record); err != nil { 68 | log.Printf("更新备份记录 ID=%d 失败: %v", record.ID, err) 69 | stats.processFailed++ 70 | continue 71 | } 72 | 73 | log.Printf("已将备份记录 ID=%d 从'运行中'状态标记为'失败'", record.ID) 74 | stats.processSuccess++ 75 | } 76 | 77 | return nil 78 | } 79 | 80 | // initPendingRecords 处理系统启动时处于"等待中"状态且超过1小时的备份记录 81 | // 这些记录可能是由于系统异常导致无法启动的任务 82 | func initPendingRecords(stats *processStats) error { 83 | recordRepo := repository.NewBackupRecordRepository() 84 | 85 | // 查找所有处于"等待中"状态的记录 86 | pendingRecords, err := recordRepo.FindByStatus(entity.StatusPending) 87 | if err != nil { 88 | return fmt.Errorf("查询等待中的备份记录失败: %w", err) 89 | } 90 | 91 | // 当前时间 92 | now := time.Now() 93 | oneHourAgo := now.Add(-1 * time.Hour) 94 | 95 | // 统计需要处理的记录数 96 | for _, record := range pendingRecords { 97 | if record.StartTime.Before(oneHourAgo) { 98 | stats.pendingCount++ 99 | } 100 | } 101 | 102 | log.Printf("找到 %d 条长时间(超过1小时)处于等待中状态的备份记录,将标记为失败", stats.pendingCount) 103 | 104 | // 处理每一条记录 105 | for _, record := range pendingRecords { 106 | // 只处理超过1小时的待处理记录 107 | if record.StartTime.Before(oneHourAgo) { 108 | // 设置记录状态为失败 109 | record.Status = entity.StatusFailed 110 | record.EndTime = now 111 | record.ErrorMessage = "系统重启时,该任务长时间处于等待中状态,已被自动标记为失败" 112 | 113 | // 更新记录 114 | if err := recordRepo.Update(record); err != nil { 115 | log.Printf("更新备份记录 ID=%d 失败: %v", record.ID, err) 116 | stats.processFailed++ 117 | continue 118 | } 119 | 120 | log.Printf("已将备份记录 ID=%d 从'等待中'状态标记为'失败'", record.ID) 121 | stats.processSuccess++ 122 | } 123 | } 124 | 125 | return nil 126 | } 127 | -------------------------------------------------------------------------------- /service/backup/database_backup.go: -------------------------------------------------------------------------------- 1 | package backup 2 | 3 | import ( 4 | "backup-go/entity" 5 | "backup-go/repository" 6 | "backup-go/service/config" 7 | "backup-go/service/storage" 8 | "fmt" 9 | "io/ioutil" 10 | "log" 11 | "os" 12 | "os/exec" 13 | "path/filepath" 14 | "strings" 15 | "time" 16 | "unicode" 17 | ) 18 | 19 | // DatabaseBackupService 数据库备份服务 20 | type DatabaseBackupService struct { 21 | taskRepo *repository.BackupTaskRepository 22 | recordRepo *repository.BackupRecordRepository 23 | storageType entity.StorageType 24 | webhookService *config.WebhookService 25 | } 26 | 27 | // NewDatabaseBackupService 创建数据库备份服务 28 | func NewDatabaseBackupService() *DatabaseBackupService { 29 | return &DatabaseBackupService{ 30 | taskRepo: repository.NewBackupTaskRepository(), 31 | recordRepo: repository.NewBackupRecordRepository(), 32 | storageType: entity.LocalStorage, // 默认使用本地存储 33 | webhookService: config.NewWebhookService(), 34 | } 35 | } 36 | 37 | // Execute 执行备份 38 | func (s *DatabaseBackupService) Execute(task *entity.BackupTask) (*entity.BackupRecord, error) { 39 | // 解析源信息 40 | sourceInfo, err := s.taskRepo.ParseDatabaseSourceInfo(task) 41 | if err != nil { 42 | return nil, fmt.Errorf("failed to parse database source info: %w", err) 43 | } 44 | 45 | // 创建备份记录 46 | record := &entity.BackupRecord{ 47 | TaskID: task.ID, 48 | Status: entity.StatusRunning, 49 | StartTime: time.Now(), 50 | } 51 | err = s.recordRepo.Create(record) 52 | if err != nil { 53 | return nil, fmt.Errorf("failed to create backup record: %w", err) 54 | } 55 | 56 | // 执行备份 57 | backupVersion := time.Now().Format("20060102150405") 58 | 59 | // 生成文件名,添加任务ID和任务名称以避免重复 60 | // 为任务名称去除特殊字符,避免不合法的文件名 61 | safeName := strings.Map(func(r rune) rune { 62 | if unicode.IsLetter(r) || unicode.IsDigit(r) || r == '_' || r == '-' { 63 | return r 64 | } 65 | return '_' 66 | }, task.Name) 67 | 68 | var filename string 69 | if sourceInfo.Database == "" || sourceInfo.Database == "all" { 70 | filename = fmt.Sprintf("task_%d_%s_all_databases_%s.sql", task.ID, safeName, backupVersion) 71 | } else { 72 | filename = fmt.Sprintf("task_%d_%s_%s_%s.sql", task.ID, safeName, sourceInfo.Database, backupVersion) 73 | } 74 | 75 | // 创建临时目录 76 | //tempDir, err := ioutil.TempDir("", "db_backup") 77 | tempDir, err := ioutil.TempDir("", "db_backup") 78 | if err != nil { 79 | s.updateRecordStatus(record, entity.StatusFailed, err.Error()) 80 | return record, fmt.Errorf("failed to create temp directory: %w", err) 81 | } 82 | defer os.RemoveAll(tempDir) 83 | 84 | tempFilePath := filepath.Join(tempDir, filename) 85 | 86 | // 执行备份命令 87 | var cmd *exec.Cmd 88 | switch sourceInfo.Type { 89 | case "mysql": 90 | // 构造基本的mysqldump命令参数 91 | args := []string{ 92 | "-h" + sourceInfo.Host, 93 | "-P" + fmt.Sprintf("%d", sourceInfo.Port), 94 | "-u" + sourceInfo.User, 95 | "-p" + sourceInfo.Password, 96 | "--result-file=" + tempFilePath, 97 | "--ssl-mode=DISABLED", // mysql 98 | //"--ssl=0", // 打包 mariadb 99 | } 100 | 101 | // 判断是否为全库备份(备份所有数据库) 102 | if sourceInfo.Database == "" || sourceInfo.Database == "all" { 103 | // 备份所有数据库 104 | args = append(args, "--all-databases") 105 | } else { 106 | // 备份指定的数据库 107 | args = append(args, "--databases", sourceInfo.Database) 108 | } 109 | 110 | // 用于调试,输出执行的命令 111 | cmdStr := "mysqldump " 112 | for _, arg := range args { 113 | cmdStr += arg + " " 114 | } 115 | log.Println(cmdStr) 116 | 117 | cmd = exec.Command("mysqldump", args...) 118 | default: 119 | err := fmt.Errorf("unsupported database type: %s", sourceInfo.Type) 120 | s.updateRecordStatus(record, entity.StatusFailed, err.Error()) 121 | return record, err 122 | } 123 | 124 | // 执行命令 125 | if err := cmd.Run(); err != nil { 126 | s.updateRecordStatus(record, entity.StatusFailed, err.Error()) 127 | return record, fmt.Errorf("backup command failed: %w", err) 128 | } 129 | 130 | // 获取文件大小 131 | fileInfo, err := os.Stat(tempFilePath) 132 | if err != nil { 133 | s.updateRecordStatus(record, entity.StatusFailed, err.Error()) 134 | return record, fmt.Errorf("failed to get file info: %w", err) 135 | } 136 | 137 | // 读取备份文件 138 | backupData, err := os.Open(tempFilePath) 139 | if err != nil { 140 | s.updateRecordStatus(record, entity.StatusFailed, err.Error()) 141 | return record, fmt.Errorf("failed to read backup file: %w", err) 142 | } 143 | defer backupData.Close() 144 | 145 | // 上传到存储 146 | storageService, err := storage.NewStorageService("") 147 | if err != nil { 148 | s.updateRecordStatus(record, entity.StatusFailed, err.Error()) 149 | return record, fmt.Errorf("failed to create storage service: %w", err) 150 | } 151 | 152 | filePath, err := storageService.Save(filename, backupData) 153 | if err != nil { 154 | s.updateRecordStatus(record, entity.StatusFailed, err.Error()) 155 | return record, fmt.Errorf("failed to save backup file: %w", err) 156 | } 157 | 158 | // 更新记录 159 | record.Status = entity.StatusSuccess 160 | record.EndTime = time.Now() 161 | record.FileSize = fileInfo.Size() 162 | record.FilePath = filePath 163 | record.BackupVersion = backupVersion 164 | record.StorageType = storageService.GetStorageType() 165 | 166 | if err := s.recordRepo.Update(record); err != nil { 167 | return record, fmt.Errorf("failed to update backup record: %w", err) 168 | } 169 | 170 | // 发送备份成功通知 171 | task, tErr := s.taskRepo.FindByID(record.TaskID) 172 | if tErr == nil && task != nil { 173 | duration := record.EndTime.Sub(record.StartTime) 174 | // 尝试发送通知,忽略错误 175 | _ = s.webhookService.SendBackupSuccessNotification( 176 | task.Name, 177 | record.FileSize, 178 | record.FilePath, 179 | duration, 180 | ) 181 | } 182 | 183 | return record, nil 184 | } 185 | 186 | // GetBackupType 获取备份类型 187 | func (s *DatabaseBackupService) GetBackupType() entity.BackupType { 188 | return entity.DatabaseBackup 189 | } 190 | 191 | // 更新记录状态 192 | func (s *DatabaseBackupService) updateRecordStatus(record *entity.BackupRecord, status entity.BackupStatus, errorMsg string) { 193 | record.Status = status 194 | record.EndTime = time.Now() 195 | record.ErrorMessage = errorMsg 196 | 197 | _ = s.recordRepo.Update(record) 198 | 199 | // 如果是失败状态,发送Webhook通知 200 | if status == entity.StatusFailed { 201 | task, err := s.taskRepo.FindByID(record.TaskID) 202 | if err == nil && task != nil { 203 | // 尝试发送通知,忽略错误 204 | _ = s.webhookService.SendBackupFailureNotification( 205 | task.Name, 206 | errorMsg, 207 | ) 208 | } 209 | } 210 | } 211 | -------------------------------------------------------------------------------- /service/backup/file_backup.go: -------------------------------------------------------------------------------- 1 | package backup 2 | 3 | import ( 4 | "archive/zip" 5 | "backup-go/entity" 6 | "backup-go/repository" 7 | "backup-go/service/config" 8 | "backup-go/service/storage" 9 | "fmt" 10 | "io" 11 | "io/ioutil" 12 | "os" 13 | "path/filepath" 14 | "time" 15 | ) 16 | 17 | // FileBackupService 文件备份服务 18 | type FileBackupService struct { 19 | taskRepo *repository.BackupTaskRepository 20 | recordRepo *repository.BackupRecordRepository 21 | storageType entity.StorageType 22 | webhookService *config.WebhookService 23 | } 24 | 25 | // NewFileBackupService 创建文件备份服务 26 | func NewFileBackupService() *FileBackupService { 27 | return &FileBackupService{ 28 | taskRepo: repository.NewBackupTaskRepository(), 29 | recordRepo: repository.NewBackupRecordRepository(), 30 | storageType: entity.LocalStorage, // 默认使用本地存储 31 | webhookService: config.NewWebhookService(), 32 | } 33 | } 34 | 35 | // Execute 执行备份 36 | func (s *FileBackupService) Execute(task *entity.BackupTask) (*entity.BackupRecord, error) { 37 | // 解析源信息 38 | sourceInfo, err := s.taskRepo.ParseFileSourceInfo(task) 39 | if err != nil { 40 | return nil, fmt.Errorf("failed to parse file source info: %w", err) 41 | } 42 | 43 | // 创建备份记录 44 | record := &entity.BackupRecord{ 45 | TaskID: task.ID, 46 | Status: entity.StatusRunning, 47 | StartTime: time.Now(), 48 | } 49 | err = s.recordRepo.Create(record) 50 | if err != nil { 51 | return nil, fmt.Errorf("failed to create backup record: %w", err) 52 | } 53 | 54 | // 执行备份 55 | backupVersion := time.Now().Format("20060102150405") 56 | filename := fmt.Sprintf("files_%s.zip", backupVersion) 57 | 58 | // 创建临时目录 59 | tempDir, err := ioutil.TempDir("", "file_backup") 60 | if err != nil { 61 | s.updateRecordStatus(record, entity.StatusFailed, err.Error()) 62 | return record, fmt.Errorf("failed to create temp directory: %w", err) 63 | } 64 | defer os.RemoveAll(tempDir) 65 | 66 | // 创建ZIP文件 67 | tempFilePath := filepath.Join(tempDir, filename) 68 | zipFile, err := os.Create(tempFilePath) 69 | if err != nil { 70 | s.updateRecordStatus(record, entity.StatusFailed, err.Error()) 71 | return record, fmt.Errorf("failed to create zip file: %w", err) 72 | } 73 | 74 | zipWriter := zip.NewWriter(zipFile) 75 | defer zipWriter.Close() 76 | 77 | // 添加文件到ZIP 78 | for _, path := range sourceInfo.Paths { 79 | err = s.addFileToZip(zipWriter, path, "") 80 | if err != nil { 81 | zipWriter.Close() 82 | zipFile.Close() 83 | s.updateRecordStatus(record, entity.StatusFailed, err.Error()) 84 | return record, fmt.Errorf("failed to add file to zip: %w", err) 85 | } 86 | } 87 | 88 | // 关闭ZIP writer 89 | err = zipWriter.Close() 90 | if err != nil { 91 | zipFile.Close() 92 | s.updateRecordStatus(record, entity.StatusFailed, err.Error()) 93 | return record, fmt.Errorf("failed to close zip writer: %w", err) 94 | } 95 | 96 | // 关闭文件 97 | defer zipFile.Close() 98 | 99 | // 获取ZIP文件信息 100 | fileInfo, err := zipFile.Stat() 101 | if err != nil { 102 | s.updateRecordStatus(record, entity.StatusFailed, err.Error()) 103 | return record, fmt.Errorf("failed to get file info: %w", err) 104 | } 105 | 106 | // 上传到存储 107 | storageService, err := storage.NewStorageService("") 108 | if err != nil { 109 | s.updateRecordStatus(record, entity.StatusFailed, err.Error()) 110 | return record, fmt.Errorf("failed to create storage service: %w", err) 111 | } 112 | 113 | // 重新打开文件用于上传 114 | zipFile.Seek(0, 0) 115 | filePath, err := storageService.Save(filename, zipFile) 116 | if err != nil { 117 | s.updateRecordStatus(record, entity.StatusFailed, err.Error()) 118 | return record, fmt.Errorf("failed to save backup file: %w", err) 119 | } 120 | 121 | // 更新记录 122 | record.Status = entity.StatusSuccess 123 | record.EndTime = time.Now() 124 | record.FileSize = fileInfo.Size() 125 | record.FilePath = filePath 126 | record.BackupVersion = backupVersion 127 | record.StorageType = storageService.GetStorageType() 128 | 129 | if err := s.recordRepo.Update(record); err != nil { 130 | return record, fmt.Errorf("failed to update backup record: %w", err) 131 | } 132 | 133 | // 发送备份成功通知 134 | task, tErr := s.taskRepo.FindByID(record.TaskID) 135 | if tErr == nil && task != nil { 136 | duration := record.EndTime.Sub(record.StartTime) 137 | // 尝试发送通知,忽略错误 138 | _ = s.webhookService.SendBackupSuccessNotification( 139 | task.Name, 140 | record.FileSize, 141 | record.FilePath, 142 | duration, 143 | ) 144 | } 145 | 146 | return record, nil 147 | } 148 | 149 | // 添加文件到ZIP 150 | func (s *FileBackupService) addFileToZip(zipWriter *zip.Writer, path, baseInZip string) error { 151 | // 获取文件信息 152 | info, err := os.Stat(path) 153 | if err != nil { 154 | return fmt.Errorf("failed to get file info: %w", err) 155 | } 156 | 157 | // 构建ZIP中的路径 158 | var zipPath string 159 | if baseInZip == "" { 160 | zipPath = filepath.Base(path) 161 | } else { 162 | zipPath = filepath.Join(baseInZip, filepath.Base(path)) 163 | } 164 | 165 | // 处理目录 166 | if info.IsDir() { 167 | // 为目录创建条目 168 | if zipPath != "" { 169 | _, err = zipWriter.Create(zipPath + "/") 170 | if err != nil { 171 | return err 172 | } 173 | } 174 | 175 | // 读取目录内容 176 | files, err := ioutil.ReadDir(path) 177 | if err != nil { 178 | return err 179 | } 180 | 181 | // 递归处理子文件和子目录 182 | for _, file := range files { 183 | filePath := filepath.Join(path, file.Name()) 184 | err = s.addFileToZip(zipWriter, filePath, zipPath) 185 | if err != nil { 186 | return err 187 | } 188 | } 189 | return nil 190 | } 191 | 192 | // 处理普通文件 193 | fileToZip, err := os.Open(path) 194 | if err != nil { 195 | return err 196 | } 197 | defer fileToZip.Close() 198 | 199 | // 创建ZIP中的文件 200 | writer, err := zipWriter.Create(zipPath) 201 | if err != nil { 202 | return err 203 | } 204 | 205 | // 写入内容 206 | _, err = io.Copy(writer, fileToZip) 207 | if err != nil { 208 | return err 209 | } 210 | 211 | return nil 212 | } 213 | 214 | // GetBackupType 获取备份类型 215 | func (s *FileBackupService) GetBackupType() entity.BackupType { 216 | return entity.FileBackup 217 | } 218 | 219 | // 更新记录状态 220 | func (s *FileBackupService) updateRecordStatus(record *entity.BackupRecord, status entity.BackupStatus, errorMsg string) { 221 | record.Status = status 222 | record.EndTime = time.Now() 223 | record.ErrorMessage = errorMsg 224 | 225 | _ = s.recordRepo.Update(record) 226 | 227 | // 如果是失败状态,发送Webhook通知 228 | if status == entity.StatusFailed { 229 | task, err := s.taskRepo.FindByID(record.TaskID) 230 | if err == nil && task != nil { 231 | // 尝试发送通知,忽略错误 232 | _ = s.webhookService.SendBackupFailureNotification( 233 | task.Name, 234 | errorMsg, 235 | ) 236 | } 237 | } 238 | } 239 | -------------------------------------------------------------------------------- /service/cleanup/cleanup_service.go: -------------------------------------------------------------------------------- 1 | package cleanup 2 | 3 | import ( 4 | "backup-go/entity" 5 | "backup-go/repository" 6 | "backup-go/service/config" 7 | "log" 8 | "os" 9 | "path/filepath" 10 | "strconv" 11 | "sync" 12 | "time" 13 | 14 | "github.com/aws/aws-sdk-go/aws" 15 | "github.com/aws/aws-sdk-go/aws/credentials" 16 | "github.com/aws/aws-sdk-go/aws/session" 17 | "github.com/aws/aws-sdk-go/service/s3" 18 | "github.com/robfig/cron/v3" 19 | ) 20 | 21 | // CleanupService 清理服务 22 | type CleanupService struct { 23 | cron *cron.Cron 24 | configService *config.ConfigService 25 | recordRepo *repository.BackupRecordRepository 26 | webhookService *config.WebhookService 27 | cronEntryID cron.EntryID 28 | mutex sync.Mutex 29 | running bool 30 | } 31 | 32 | var ( 33 | instance *CleanupService 34 | once sync.Once 35 | ) 36 | 37 | // GetCleanupService 获取清理服务单例 38 | func GetCleanupService() *CleanupService { 39 | once.Do(func() { 40 | instance = &CleanupService{ 41 | cron: cron.New(cron.WithSeconds()), 42 | configService: config.NewConfigService(), 43 | recordRepo: repository.NewBackupRecordRepository(), 44 | webhookService: config.NewWebhookService(), 45 | } 46 | }) 47 | return instance 48 | } 49 | 50 | // Start 启动清理服务 51 | func (s *CleanupService) Start() { 52 | s.mutex.Lock() 53 | defer s.mutex.Unlock() 54 | 55 | if s.running { 56 | log.Println("清理服务已经在运行") 57 | return 58 | } 59 | 60 | // 设置每天凌晨2点运行清理任务 61 | var err error 62 | s.cronEntryID, err = s.cron.AddFunc("0 0 2 * * *", s.cleanup) 63 | if err != nil { 64 | log.Printf("添加清理任务失败: %v", err) 65 | return 66 | } 67 | 68 | // 启动cron 69 | s.cron.Start() 70 | s.running = true 71 | log.Println("清理服务启动成功,将在每天凌晨2点执行清理任务") 72 | } 73 | 74 | // Stop 停止清理服务 75 | func (s *CleanupService) Stop() { 76 | s.mutex.Lock() 77 | defer s.mutex.Unlock() 78 | 79 | if !s.running { 80 | return 81 | } 82 | 83 | ctx := s.cron.Stop() 84 | <-ctx.Done() 85 | s.running = false 86 | log.Println("清理服务已停止") 87 | } 88 | 89 | // ExecuteNow 立即执行清理 90 | func (s *CleanupService) ExecuteNow() { 91 | go s.cleanup() 92 | } 93 | 94 | // ExecuteAndGetResult 执行清理并返回结果 95 | func (s *CleanupService) ExecuteAndGetResult() *CleanupResult { 96 | log.Println("开始执行清理任务...") 97 | result := &CleanupResult{ 98 | ErrorMessages: []string{}, 99 | } 100 | 101 | // 获取清理天数配置 102 | daysStr, err := s.configService.GetConfigValue("system.autoCleanupDays") 103 | if err != nil { 104 | errMsg := "获取清理天数配置失败: " + err.Error() 105 | log.Println(errMsg) 106 | result.ErrorMessages = append(result.ErrorMessages, errMsg) 107 | return result 108 | } 109 | 110 | days, err := strconv.Atoi(daysStr) 111 | if err != nil { 112 | errMsg := "清理天数配置值无效: " + err.Error() 113 | log.Println(errMsg) 114 | result.ErrorMessages = append(result.ErrorMessages, errMsg) 115 | return result 116 | } 117 | 118 | // 0表示不清理 119 | if days <= 0 { 120 | log.Println("清理天数设置为0,跳过清理") 121 | result.ErrorMessages = append(result.ErrorMessages, "清理天数设置为0,跳过清理") 122 | return result 123 | } 124 | 125 | // 计算清理日期 126 | cleanupDate := time.Now().AddDate(0, 0, -days) 127 | log.Printf("将清理%d天前(%s)之前的备份文件", days, cleanupDate.Format("2006-01-02")) 128 | 129 | // 获取所有需要清理的记录 130 | records, err := s.recordRepo.FindOlderThan(cleanupDate) 131 | if err != nil { 132 | errMsg := "查询过期备份记录失败: " + err.Error() 133 | log.Println(errMsg) 134 | result.ErrorMessages = append(result.ErrorMessages, errMsg) 135 | return result 136 | } 137 | 138 | log.Printf("找到%d条需要清理的备份记录", len(records)) 139 | if len(records) == 0 { 140 | return result 141 | } 142 | 143 | // 获取存储配置(这些只用于本地存储路径) 144 | localPath, _ := s.configService.GetConfigValue("storage.localPath") 145 | if localPath == "" { 146 | localPath = "./backup" // 默认路径 147 | } 148 | 149 | // 获取S3配置信息 150 | s3Endpoint, _ := s.configService.GetConfigValue("storage.s3Endpoint") 151 | s3Region, _ := s.configService.GetConfigValue("storage.s3Region") 152 | s3AccessKey, _ := s.configService.GetConfigValue("storage.s3AccessKey") 153 | s3SecretKey, _ := s.configService.GetConfigValue("storage.s3SecretKey") 154 | s3Bucket, _ := s.configService.GetConfigValue("storage.s3Bucket") 155 | 156 | // 清理记录 157 | for _, record := range records { 158 | // 根据记录中的StorageType来处理不同的存储类型 159 | switch record.StorageType { 160 | case entity.LocalStorage: 161 | // 获取本地存储路径配置 162 | localPath, _ := s.configService.GetConfigValue("storage.localPath") 163 | if localPath == "" { 164 | localPath = "backups" 165 | } 166 | // 本地存储文件清理 167 | if s.cleanupLocalFile(record, localPath) { 168 | result.Success++ 169 | } else { 170 | result.Failed++ 171 | } 172 | case entity.S3Storage: 173 | // 检查S3配置是否有效 174 | if s3Endpoint == "" || s3AccessKey == "" || s3SecretKey == "" || s3Bucket == "" { 175 | errMsg := "S3配置不完整,跳过S3文件清理: " + record.FilePath 176 | log.Println(errMsg) 177 | result.Skipped++ 178 | result.ErrorMessages = append(result.ErrorMessages, errMsg) 179 | continue 180 | } 181 | // S3存储文件清理 182 | if s.cleanupS3File(record, s3Endpoint, s3Region, s3AccessKey, s3SecretKey, s3Bucket) { 183 | result.Success++ 184 | } else { 185 | result.Failed++ 186 | } 187 | default: 188 | errMsg := "未知的存储类型: " + string(record.StorageType) 189 | log.Println(errMsg) 190 | result.Skipped++ 191 | result.ErrorMessages = append(result.ErrorMessages, errMsg) 192 | continue 193 | } 194 | } 195 | 196 | log.Printf("清理任务完成。成功: %d, 失败: %d, 跳过: %d", result.Success, result.Failed, result.Skipped) 197 | return result 198 | } 199 | 200 | // cleanup 执行清理任务 201 | func (s *CleanupService) cleanup() { 202 | result := s.ExecuteAndGetResult() 203 | log.Printf("清理任务完成。成功: %d, 失败: %d, 跳过: %d", result.Success, result.Failed, result.Skipped) 204 | for _, errMsg := range result.ErrorMessages { 205 | log.Println("清理错误: " + errMsg) 206 | } 207 | 208 | // 发送webhook通知 209 | if err := s.webhookService.SendCleanupNotification(result.Success, result.Failed, result.Skipped, true, result.ErrorMessages); err != nil { 210 | log.Printf("发送清理通知失败: %v", err) 211 | } 212 | } 213 | 214 | // cleanupLocalFile 清理本地文件 215 | func (s *CleanupService) cleanupLocalFile(record *entity.BackupRecord, localPath string) bool { 216 | if record.FilePath == "" { 217 | log.Printf("记录 %d 没有文件路径,跳过", record.ID) 218 | return true 219 | } 220 | 221 | // 本地存储,直接删除文件 222 | filePath := record.FilePath 223 | if !filepath.IsAbs(filePath) { 224 | filePath = filepath.Join(localPath, filePath) 225 | } 226 | 227 | if err := os.Remove(filePath); err != nil { 228 | if os.IsNotExist(err) { 229 | log.Printf("文件已不存在: %s", filePath) 230 | // 更新数据库记录 231 | record.FilePath = "" 232 | record.FileSize = 0 233 | record.Status = "cleaned" // 标记为已清理状态 234 | record.ErrorMessage = "文件已被自动清理" 235 | if err := s.recordRepo.Update(record); err != nil { 236 | log.Printf("更新记录失败: %v", err) 237 | return false 238 | } 239 | return true 240 | } 241 | log.Printf("删除本地文件失败: %s, 错误: %v", filePath, err) 242 | return false 243 | } 244 | 245 | // 更新数据库记录 246 | record.FilePath = "" 247 | record.FileSize = 0 248 | record.Status = "cleaned" // 标记为已清理状态 249 | record.ErrorMessage = "文件已被自动清理" 250 | if err := s.recordRepo.Update(record); err != nil { 251 | log.Printf("更新记录失败: %v", err) 252 | return false 253 | } 254 | return true 255 | } 256 | 257 | // cleanupS3File 清理S3文件 258 | func (s *CleanupService) cleanupS3File(record *entity.BackupRecord, endpoint, region, accessKey, secretKey, bucket string) bool { 259 | if record.FilePath == "" { 260 | log.Printf("记录 %d 没有文件路径,跳过", record.ID) 261 | return true 262 | } 263 | 264 | // 数据库中存储的就是相对路径,直接用作S3对象键 265 | s3Key := record.FilePath 266 | log.Printf("使用S3对象键: %s", s3Key) 267 | 268 | // 使用AWS SDK删除S3文件 269 | sess, err := session.NewSession(&aws.Config{ 270 | Endpoint: aws.String(endpoint), 271 | Region: aws.String(region), 272 | Credentials: credentials.NewStaticCredentials(accessKey, secretKey, ""), 273 | S3ForcePathStyle: aws.Bool(true), 274 | }) 275 | 276 | if err != nil { 277 | log.Printf("创建S3会话失败: %v", err) 278 | return false 279 | } 280 | 281 | // 创建S3客户端 282 | svc := s3.New(sess) 283 | 284 | // 删除S3对象 285 | input := &s3.DeleteObjectInput{ 286 | Bucket: aws.String(bucket), 287 | Key: aws.String(s3Key), 288 | } 289 | 290 | _, err = svc.DeleteObject(input) 291 | if err != nil { 292 | log.Printf("删除S3文件失败: %s, 错误: %v", s3Key, err) 293 | return false 294 | } 295 | 296 | log.Printf("成功删除S3文件: %s", s3Key) 297 | 298 | // 更新数据库记录 299 | record.FilePath = "" 300 | record.FileSize = 0 301 | record.Status = "cleaned" // 标记为已清理状态 302 | record.ErrorMessage = "文件已被自动清理" 303 | if err := s.recordRepo.Update(record); err != nil { 304 | log.Printf("更新记录失败: %v", err) 305 | return false 306 | } 307 | return true 308 | } 309 | 310 | // CleanupResult 清理结果 311 | type CleanupResult struct { 312 | Success int 313 | Failed int 314 | Skipped int 315 | ErrorMessages []string 316 | } 317 | 318 | // GetWebhookService 获取webhook服务 319 | func (s *CleanupService) GetWebhookService() *config.WebhookService { 320 | return s.webhookService 321 | } 322 | 323 | // SendCleanupNotification 发送清理完成通知 324 | func (s *CleanupService) SendCleanupNotification(success, failed, skipped int, isAuto bool, errorMessages []string) error { 325 | return s.webhookService.SendCleanupNotification(success, failed, skipped, isAuto, errorMessages) 326 | } 327 | -------------------------------------------------------------------------------- /service/config/config_service.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | import ( 4 | "backup-go/entity" 5 | "backup-go/repository" 6 | ) 7 | 8 | // ConfigService 配置服务 9 | type ConfigService struct { 10 | repo *repository.ConfigRepository 11 | } 12 | 13 | // NewConfigService 创建配置服务 14 | func NewConfigService() *ConfigService { 15 | return &ConfigService{ 16 | repo: repository.NewConfigRepository(), 17 | } 18 | } 19 | 20 | // InitDefaultConfigs 初始化默认配置 21 | func (s *ConfigService) InitDefaultConfigs() error { 22 | // 要初始化的默认配置 23 | defaultConfigs := []struct { 24 | Key string 25 | Value string 26 | Description string 27 | }{ 28 | {"system.password", "123456", "系统登录密码"}, 29 | {"storage.type", "local", "存储类型"}, 30 | {"storage.localPath", "./backup", "本地存储路径"}, 31 | // 添加S3相关配置 32 | {"storage.s3Endpoint", "", "S3服务端点"}, 33 | {"storage.s3Region", "us-east-1", "S3区域"}, 34 | {"storage.s3AccessKey", "", "S3访问密钥"}, 35 | {"storage.s3SecretKey", "", "S3私有密钥"}, 36 | {"storage.s3Bucket", "", "S3存储桶名称"}, 37 | // 添加系统自动清理配置 38 | {"system.autoCleanupDays", "90", "自动清理天数,0表示不清理"}, 39 | // 添加Webhook相关配置 40 | {"webhook.enabled", "false", "是否启用Webhook通知"}, 41 | {"webhook.url", "", "Webhook URL"}, 42 | {"webhook.headers", "", "Webhook请求头,一行一个"}, 43 | {"webhook.body", `{"event":"${event}","taskName":"${taskName}","message":"${message}"}`, "Webhook请求体模板"}, 44 | // 添加站点配置 45 | {"system.siteName", "备份系统", "站点名称"}, 46 | } 47 | 48 | // 逐个检查配置是否存在,不存在则创建 49 | for _, cfg := range defaultConfigs { 50 | // 检查配置是否存在 51 | _, err := s.GetConfigByKey(cfg.Key) 52 | if err != nil { 53 | // 配置不存在,创建默认配置 54 | config := &entity.SystemConfig{ 55 | ConfigKey: cfg.Key, 56 | ConfigValue: cfg.Value, 57 | Description: cfg.Description, 58 | } 59 | if err := s.CreateConfig(config); err != nil { 60 | return err 61 | } 62 | } 63 | } 64 | 65 | return nil 66 | } 67 | 68 | // GetAllConfigs 获取所有配置 69 | func (s *ConfigService) GetAllConfigs() ([]*entity.SystemConfig, error) { 70 | return s.repo.FindAll() 71 | } 72 | 73 | // GetConfig 获取配置 74 | func (s *ConfigService) GetConfig(id uint) (*entity.SystemConfig, error) { 75 | return s.repo.FindByID(id) 76 | } 77 | 78 | // GetConfigByKey 通过key获取配置 79 | func (s *ConfigService) GetConfigByKey(key string) (*entity.SystemConfig, error) { 80 | return s.repo.FindByKey(key) 81 | } 82 | 83 | // CreateConfig 创建配置 84 | func (s *ConfigService) CreateConfig(config *entity.SystemConfig) error { 85 | return s.repo.Create(config) 86 | } 87 | 88 | // UpdateConfig 更新配置 89 | func (s *ConfigService) UpdateConfig(config *entity.SystemConfig) error { 90 | return s.repo.Update(config) 91 | } 92 | 93 | // DeleteConfig 删除配置 94 | func (s *ConfigService) DeleteConfig(id uint) error { 95 | return s.repo.Delete(id) 96 | } 97 | 98 | // GetConfigValue 获取配置值 99 | func (s *ConfigService) GetConfigValue(key string) (string, error) { 100 | config, err := s.GetConfigByKey(key) 101 | if err != nil { 102 | return "", err 103 | } 104 | return config.ConfigValue, nil 105 | } 106 | 107 | // SetConfigValue 设置配置值 108 | func (s *ConfigService) SetConfigValue(key string, value string, description string) error { 109 | // 先检查是否存在 110 | config, err := s.GetConfigByKey(key) 111 | if err != nil { 112 | // 不存在则创建 113 | if err.Error() == "配置不存在" { 114 | newConfig := &entity.SystemConfig{ 115 | ConfigKey: key, 116 | ConfigValue: value, 117 | Description: description, 118 | } 119 | return s.CreateConfig(newConfig) 120 | } 121 | return err 122 | } 123 | 124 | // 存在则更新 125 | config.ConfigValue = value 126 | if description != "" { 127 | config.Description = description 128 | } 129 | return s.UpdateConfig(config) 130 | } 131 | 132 | // GetOrCreateConfig 获取或创建配置 133 | func (s *ConfigService) GetOrCreateConfig(key string, defaultValue string, description string) (*entity.SystemConfig, error) { 134 | config, err := s.GetConfigByKey(key) 135 | if err != nil { 136 | // 配置不存在,创建新配置 137 | config = &entity.SystemConfig{ 138 | ConfigKey: key, 139 | ConfigValue: defaultValue, 140 | Description: description, 141 | } 142 | if err := s.CreateConfig(config); err != nil { 143 | return nil, err 144 | } 145 | return config, nil 146 | } 147 | return config, nil 148 | } 149 | 150 | // GetConfigValueByKey 获取配置值,如果不存在则返回错误 151 | func (s *ConfigService) GetConfigValueByKey(key string) (string, error) { 152 | config, err := s.GetConfigByKey(key) 153 | if err != nil { 154 | return "", err 155 | } 156 | return config.ConfigValue, nil 157 | } 158 | 159 | // GetConfigValueOrDefault 获取配置值,如果不存在则返回默认值 160 | func (s *ConfigService) GetConfigValueOrDefault(key string, defaultValue string) string { 161 | value, err := s.GetConfigValueByKey(key) 162 | if err != nil || value == "" { 163 | return defaultValue 164 | } 165 | return value 166 | } 167 | -------------------------------------------------------------------------------- /service/config/webhook_service.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | import ( 4 | "fmt" 5 | "log" 6 | "net/url" 7 | "strings" 8 | "time" 9 | 10 | "github.com/go-resty/resty/v2" 11 | ) 12 | 13 | // WebhookService webhook服务 14 | type WebhookService struct { 15 | configService *ConfigService 16 | } 17 | 18 | // WebhookData webhook数据 19 | type WebhookData struct { 20 | TaskName string `json:"taskName,omitempty"` 21 | Event string `json:"event,omitempty"` 22 | Message string `json:"message,omitempty"` 23 | TestMessage string `json:"testMessage,omitempty"` 24 | } 25 | 26 | // NewWebhookService 创建webhook服务 27 | func NewWebhookService() *WebhookService { 28 | return &WebhookService{ 29 | configService: NewConfigService(), 30 | } 31 | } 32 | 33 | // SendBackupFailureNotification 发送备份失败通知 34 | func (w *WebhookService) SendBackupFailureNotification(taskName, errorMessage string) error { 35 | // 检查是否启用了webhook 36 | enabled, err := w.configService.GetConfigValue("webhook.enabled") 37 | if err != nil || enabled != "true" { 38 | return nil // 未启用或查询错误,不发送通知 39 | } 40 | 41 | // 准备数据 42 | data := &WebhookData{ 43 | TaskName: taskName, 44 | Event: "备份失败", 45 | Message: errorMessage, 46 | } 47 | 48 | // 发送通知 49 | return w.sendWebhook(data) 50 | } 51 | 52 | // SendBackupSuccessNotification 发送备份成功通知 53 | func (w *WebhookService) SendBackupSuccessNotification(taskName string, fileSize int64, filePath string, duration time.Duration) error { 54 | // 检查是否启用了webhook 55 | enabled, err := w.configService.GetConfigValue("webhook.enabled") 56 | if err != nil || enabled != "true" { 57 | return nil // 未启用或查询错误,不发送通知 58 | } 59 | 60 | // 格式化文件大小 61 | fileSizeStr := "0 B" 62 | if fileSize > 0 { 63 | // 简单格式化文件大小 64 | units := []string{"B", "KB", "MB", "GB", "TB"} 65 | size := float64(fileSize) 66 | unitIndex := 0 67 | for size >= 1024 && unitIndex < len(units)-1 { 68 | size /= 1024 69 | unitIndex++ 70 | } 71 | fileSizeStr = fmt.Sprintf("%.2f %s", size, units[unitIndex]) 72 | } 73 | 74 | // 格式化持续时间 75 | durationStr := fmt.Sprintf("%.2f秒", duration.Seconds()) 76 | if duration.Minutes() >= 1 { 77 | durationStr = fmt.Sprintf("%.2f分钟", duration.Minutes()) 78 | } 79 | 80 | // 准备消息内容 81 | message := fmt.Sprintf("备份完成,文件大小: %s,耗时: %s", fileSizeStr, durationStr) 82 | if filePath != "" { 83 | message += fmt.Sprintf(",文件路径: %s", filePath) 84 | } 85 | 86 | // 准备数据 87 | data := &WebhookData{ 88 | TaskName: taskName, 89 | Event: "备份成功", 90 | Message: message, 91 | } 92 | 93 | // 发送通知 94 | return w.sendWebhook(data) 95 | } 96 | 97 | // SendCleanupNotification 发送清理操作完成通知 98 | func (w *WebhookService) SendCleanupNotification(success, failed, skipped int, isAuto bool, errorMessages []string) error { 99 | // 检查是否启用了webhook 100 | enabled, err := w.configService.GetConfigValue("webhook.enabled") 101 | if err != nil || enabled != "true" { 102 | return nil // 未启用或查询错误,不发送通知 103 | } 104 | 105 | // 准备消息内容 106 | messageType := "自动" 107 | if !isAuto { 108 | messageType = "手动" 109 | } 110 | 111 | message := fmt.Sprintf("%s清理操作已完成。成功: %d, 失败: %d, 跳过: %d", messageType, success, failed, skipped) 112 | 113 | // 如果有错误消息,添加到通知中 114 | if len(errorMessages) > 0 { 115 | message += fmt.Sprintf("。发生了%d个错误,第一个错误: %s", len(errorMessages), errorMessages[0]) 116 | } 117 | 118 | // 准备数据 119 | data := &WebhookData{ 120 | TaskName: "系统清理", 121 | Event: "清理完成", 122 | Message: message, 123 | } 124 | 125 | // 发送通知 126 | return w.sendWebhook(data) 127 | } 128 | 129 | // TestWebhook 测试webhook 130 | func (w *WebhookService) TestWebhook() error { 131 | // 检查是否启用了webhook 132 | enabled, err := w.configService.GetConfigValue("webhook.enabled") 133 | if err != nil || enabled != "true" { 134 | return fmt.Errorf("webhook未启用") 135 | } 136 | 137 | // 准备测试数据 138 | data := &WebhookData{ 139 | TaskName: "测试任务", 140 | Event: "测试事件", 141 | Message: "这是一条测试消息", 142 | TestMessage: "这是一条Webhook测试消息,如果您收到了,表示配置正确。", 143 | } 144 | 145 | // 从配置中获取URL等信息 146 | url, err := w.configService.GetConfigValue("webhook.url") 147 | if err != nil || url == "" { 148 | return fmt.Errorf("webhook URL未配置") 149 | } 150 | 151 | headersStr, _ := w.configService.GetConfigValue("webhook.headers") 152 | bodyTemplate, _ := w.configService.GetConfigValue("webhook.body") 153 | 154 | // 发送测试通知 155 | return w.sendWebhookWithConfig(data, url, headersStr, bodyTemplate) 156 | } 157 | 158 | // TestWebhookWithConfig 使用临时配置测试webhook 159 | func (w *WebhookService) TestWebhookWithConfig(url, headersStr, bodyTemplate string) error { 160 | // URL已在控制器层验证,这里直接使用 161 | log.Printf("测试Webhook, URL: %s", url) 162 | 163 | // 准备测试数据 164 | data := &WebhookData{ 165 | TaskName: "测试任务", 166 | Event: "测试事件", 167 | Message: "这是一条测试消息", 168 | TestMessage: "这是一条Webhook测试消息,如果您收到了,表示配置正确。", 169 | } 170 | 171 | // 对URL进行变量替换 172 | finalURL := w.replaceVariables(url, data) 173 | 174 | // 检测是否包含特定的URL模式(bot接口+content参数) 175 | if strings.Contains(finalURL, "/bot/") && strings.Contains(finalURL, "content=") { 176 | log.Printf("检测到特殊的bot通知URL,使用专用方法处理") 177 | return w.handleChineseInURL(finalURL, data) 178 | } 179 | 180 | // 使用临时配置发送测试通知 181 | return w.sendWebhookWithConfig(data, url, headersStr, bodyTemplate) 182 | } 183 | 184 | // sendWebhook 发送webhook通知,使用已保存的配置 185 | func (w *WebhookService) sendWebhook(data *WebhookData) error { 186 | // 获取webhook配置 187 | url, err := w.configService.GetConfigValue("webhook.url") 188 | if err != nil || url == "" { 189 | return fmt.Errorf("webhook URL未配置") 190 | } 191 | 192 | headersStr, _ := w.configService.GetConfigValue("webhook.headers") 193 | bodyTemplate, _ := w.configService.GetConfigValue("webhook.body") 194 | 195 | return w.sendWebhookWithConfig(data, url, headersStr, bodyTemplate) 196 | } 197 | 198 | // sendWebhookWithConfig 使用指定配置发送webhook通知 199 | func (w *WebhookService) sendWebhookWithConfig(data *WebhookData, urlStr, headersStr, bodyTemplate string) error { 200 | // 替换变量 201 | body := w.replaceVariables(bodyTemplate, data) 202 | 203 | // 对URL也进行变量替换 204 | urlStr = w.replaceVariables(urlStr, data) 205 | 206 | // 额外处理URL,移除可能导致解析失败的字符 207 | urlStr = w.sanitizeURL(urlStr) 208 | 209 | // 确保URL编码正确 210 | parsedURL, err := url.Parse(urlStr) 211 | if err != nil { 212 | return fmt.Errorf("URL格式不正确: %w", err) 213 | } 214 | 215 | // 特殊处理URL参数,确保中文正确编码 216 | if parsedURL.RawQuery != "" { 217 | values := parsedURL.Query() 218 | parsedURL.RawQuery = values.Encode() 219 | } 220 | 221 | // 创建resty客户端 222 | client := resty.New(). 223 | SetTimeout(10 * time.Second). 224 | SetRetryCount(2). 225 | SetRetryWaitTime(500 * time.Millisecond) 226 | 227 | // 打印调试信息 228 | finalURL := parsedURL.String() 229 | log.Printf("发送webhook请求到: %s", finalURL) 230 | 231 | // 创建请求 232 | request := client.R() 233 | 234 | // 添加自定义请求头 235 | if headersStr != "" { 236 | headers := w.parseHeadersWithVariables(headersStr, data) 237 | request.SetHeaders(headers) 238 | } 239 | 240 | // 根据body决定是GET还是POST请求 241 | var resp *resty.Response 242 | 243 | if body == "" { 244 | // 如果请求体为空,则使用GET请求 245 | resp, err = request.Get(finalURL) 246 | } else { 247 | // 如果请求体不为空,则使用POST请求 248 | // 如果没有显式设置Content-Type,默认设置为application/json 249 | if headersStr == "" || !strings.Contains(strings.ToLower(headersStr), "content-type:") { 250 | request.SetHeader("Content-Type", "application/json") 251 | } 252 | resp, err = request.SetBody(body).Post(finalURL) 253 | } 254 | 255 | if err != nil { 256 | log.Printf("发送webhook失败: %s \n", err.Error()) 257 | return fmt.Errorf("发送webhook失败: %w", err) 258 | } 259 | 260 | // 检查响应状态 261 | if resp.StatusCode() != 200 { 262 | return fmt.Errorf("webhook服务返回错误状态码: %d, 响应体: %s", resp.StatusCode(), resp.String()) 263 | } 264 | 265 | log.Printf("webhook请求成功,状态码: %d", resp.StatusCode()) 266 | return nil 267 | } 268 | 269 | // replaceVariables 替换webhook模板中的变量 270 | func (w *WebhookService) replaceVariables(template string, data *WebhookData) string { 271 | if template == "" { 272 | return "" 273 | } 274 | 275 | result := template 276 | 277 | // 对message内容进行特殊处理,确保其中不包含会导致URL解析失败的特殊字符 278 | safeMessage := "" 279 | if data.Message != "" { 280 | // 替换换行符和其他可能导致URL解析失败的控制字符 281 | safeMessage = strings.Map(func(r rune) rune { 282 | // 替换换行符、回车符、制表符等控制字符 283 | if r < 32 || r == 127 { 284 | return ' ' 285 | } 286 | // 替换引号和反斜杠 287 | if r == '"' || r == '\\' || r == '\'' { 288 | return ' ' 289 | } 290 | return r 291 | }, data.Message) 292 | } 293 | 294 | result = strings.ReplaceAll(result, "${taskName}", data.TaskName) 295 | result = strings.ReplaceAll(result, "${event}", data.Event) 296 | result = strings.ReplaceAll(result, "${message}", safeMessage) 297 | 298 | // 只有测试消息才使用这个字段 299 | if data.TestMessage != "" { 300 | // 同样处理TestMessage中的特殊字符 301 | safeTestMessage := strings.Map(func(r rune) rune { 302 | if r < 32 || r == 127 || r == '"' || r == '\\' || r == '\'' { 303 | return ' ' 304 | } 305 | return r 306 | }, data.TestMessage) 307 | result = strings.ReplaceAll(result, "${testMessage}", safeTestMessage) 308 | } 309 | 310 | return result 311 | } 312 | 313 | // parseHeaders 将头部字符串解析为map 314 | func (w *WebhookService) parseHeaders(headersStr string) map[string]string { 315 | headers := make(map[string]string) 316 | 317 | if headersStr == "" { 318 | return headers 319 | } 320 | 321 | // 解析每一行的header 322 | lines := strings.Split(headersStr, "\n") 323 | for _, line := range lines { 324 | line = strings.TrimSpace(line) 325 | if line == "" { 326 | continue 327 | } 328 | 329 | // 分割header名和值 330 | parts := strings.SplitN(line, ":", 2) 331 | if len(parts) != 2 { 332 | continue 333 | } 334 | 335 | headerName := strings.TrimSpace(parts[0]) 336 | headerValue := strings.TrimSpace(parts[1]) 337 | if headerName != "" && headerValue != "" { 338 | headers[headerName] = headerValue 339 | } 340 | } 341 | 342 | return headers 343 | } 344 | 345 | // parseHeadersWithVariables 将头部字符串解析为map,并替换变量 346 | func (w *WebhookService) parseHeadersWithVariables(headersStr string, data *WebhookData) map[string]string { 347 | headers := make(map[string]string) 348 | 349 | if headersStr == "" { 350 | return headers 351 | } 352 | 353 | // 解析每一行的header 354 | lines := strings.Split(headersStr, "\n") 355 | for _, line := range lines { 356 | line = strings.TrimSpace(line) 357 | if line == "" { 358 | continue 359 | } 360 | 361 | // 分割header名和值 362 | parts := strings.SplitN(line, ":", 2) 363 | if len(parts) != 2 { 364 | continue 365 | } 366 | 367 | headerName := strings.TrimSpace(parts[0]) 368 | headerValue := strings.TrimSpace(parts[1]) 369 | 370 | // 对header值进行变量替换 371 | if headerName != "" && headerValue != "" { 372 | headerValue = w.replaceVariables(headerValue, data) 373 | headers[headerName] = headerValue 374 | } 375 | } 376 | 377 | return headers 378 | } 379 | 380 | // handleChineseInURL 专门处理包含中文的URL请求 381 | func (w *WebhookService) handleChineseInURL(urlStr string, data *WebhookData) error { 382 | // 解析URL 383 | parsedURL, err := url.Parse(urlStr) 384 | if err != nil { 385 | return fmt.Errorf("URL格式不正确: %w", err) 386 | } 387 | 388 | // 如果没有查询参数,直接使用原始URL 389 | if parsedURL.RawQuery == "" { 390 | return nil 391 | } 392 | 393 | // 获取查询参数content的值 394 | values := parsedURL.Query() 395 | contentValue := values.Get("content") 396 | 397 | // 如果没有content参数,直接返回 398 | if contentValue == "" { 399 | return nil 400 | } 401 | 402 | // 特殊处理content参数 403 | log.Printf("检测到content参数: %s,尝试直接使用resty的QueryParam", contentValue) 404 | 405 | // 创建resty客户端 406 | client := resty.New(). 407 | SetTimeout(10 * time.Second). 408 | SetRetryCount(2). 409 | SetRetryWaitTime(500 * time.Millisecond) 410 | 411 | // 创建请求 412 | baseURL := parsedURL.Scheme + "://" + parsedURL.Host + parsedURL.Path 413 | log.Printf("基础URL: %s", baseURL) 414 | 415 | // 发送请求 416 | resp, err := client.R(). 417 | SetQueryParam("content", contentValue). 418 | Get(baseURL) 419 | 420 | if err != nil { 421 | return fmt.Errorf("发送webhook失败: %w", err) 422 | } 423 | 424 | // 检查响应状态 425 | if resp.StatusCode() < 200 || resp.StatusCode() >= 300 { 426 | return fmt.Errorf("webhook服务返回错误状态码: %d, 响应体: %s", resp.StatusCode(), resp.String()) 427 | } 428 | 429 | log.Printf("webhook请求成功,状态码: %d", resp.StatusCode()) 430 | return nil 431 | } 432 | 433 | // sanitizeURL 清理URL中的特殊字符 434 | func (w *WebhookService) sanitizeURL(urlStr string) string { 435 | // 检测URL中是否含有content参数 436 | parts := strings.SplitN(urlStr, "?", 2) 437 | if len(parts) != 2 { 438 | return urlStr // 没有查询参数,直接返回 439 | } 440 | 441 | baseURL := parts[0] 442 | queryStr := parts[1] 443 | 444 | // 解析查询参数 445 | values, err := url.ParseQuery(queryStr) 446 | if err != nil { 447 | // 如果解析失败,尝试手动处理 448 | log.Printf("解析查询参数失败: %s, 尝试手动处理", err) 449 | 450 | // 分割多个参数 451 | params := strings.Split(queryStr, "&") 452 | cleanParams := []string{} 453 | 454 | for _, param := range params { 455 | // 分割参数名和值 456 | kv := strings.SplitN(param, "=", 2) 457 | if len(kv) != 2 { 458 | cleanParams = append(cleanParams, param) // 保持不变 459 | continue 460 | } 461 | 462 | key := kv[0] 463 | value := kv[1] 464 | 465 | // 对参数值进行清理和编码 466 | cleanValue := strings.Map(func(r rune) rune { 467 | // 过滤掉控制字符 468 | if r < 32 || r == 127 { 469 | return -1 // 删除字符 470 | } 471 | return r 472 | }, value) 473 | 474 | // 对所有参数值进行URL编码 475 | encodedValue := url.QueryEscape(cleanValue) 476 | cleanParams = append(cleanParams, key+"="+encodedValue) 477 | } 478 | 479 | // 重新组合URL 480 | return baseURL + "?" + strings.Join(cleanParams, "&") 481 | } 482 | 483 | // 如果解析成功,确保每个参数值都被正确编码 484 | encodedQuery := values.Encode() 485 | return baseURL + "?" + encodedQuery 486 | } 487 | -------------------------------------------------------------------------------- /service/scheduler/scheduler.go: -------------------------------------------------------------------------------- 1 | package scheduler 2 | 3 | import ( 4 | "backup-go/entity" 5 | "backup-go/repository" 6 | "backup-go/service/backup" 7 | "fmt" 8 | "log" 9 | "sync" 10 | "time" 11 | 12 | "github.com/robfig/cron/v3" 13 | ) 14 | 15 | // BackupScheduler 备份调度器 16 | type BackupScheduler struct { 17 | cron *cron.Cron 18 | taskRepo *repository.BackupTaskRepository 19 | recordRepo *repository.BackupRecordRepository 20 | jobs map[int64]cron.EntryID 21 | mutex sync.Mutex 22 | running bool 23 | } 24 | 25 | var ( 26 | scheduler *BackupScheduler 27 | once sync.Once 28 | ) 29 | 30 | // GetScheduler 获取单例的调度器 31 | func GetScheduler() *BackupScheduler { 32 | once.Do(func() { 33 | scheduler = &BackupScheduler{ 34 | cron: cron.New(cron.WithSeconds()), 35 | taskRepo: repository.NewBackupTaskRepository(), 36 | recordRepo: repository.NewBackupRecordRepository(), 37 | jobs: make(map[int64]cron.EntryID), 38 | } 39 | }) 40 | return scheduler 41 | } 42 | 43 | // Start 启动调度器 44 | func (s *BackupScheduler) Start() { 45 | s.mutex.Lock() 46 | // 检查是否已经运行 47 | if s.running { 48 | s.mutex.Unlock() 49 | return 50 | } 51 | s.mutex.Unlock() 52 | 53 | // 加载任务 - 在锁外执行 54 | if err := s.loadTasks(); err != nil { 55 | log.Printf("加载任务失败: %v", err) 56 | } 57 | 58 | s.mutex.Lock() 59 | // 启动Cron 60 | s.cron.Start() 61 | s.running = true 62 | s.mutex.Unlock() 63 | 64 | log.Println("调度器启动成功") 65 | } 66 | 67 | // Stop 停止调度器 68 | func (s *BackupScheduler) Stop() { 69 | s.mutex.Lock() 70 | defer s.mutex.Unlock() 71 | 72 | if !s.running { 73 | return 74 | } 75 | 76 | // 停止并等待所有任务完成 77 | ctx := s.cron.Stop() 78 | <-ctx.Done() 79 | 80 | s.running = false 81 | log.Println("调度器已停止") 82 | } 83 | 84 | // Reload 重新加载所有任务 85 | func (s *BackupScheduler) Reload() error { 86 | s.mutex.Lock() 87 | defer s.mutex.Unlock() 88 | 89 | // 移除所有任务 90 | for taskID, entryID := range s.jobs { 91 | s.cron.Remove(entryID) 92 | delete(s.jobs, taskID) 93 | } 94 | 95 | // 加载任务 96 | return s.loadTasks() 97 | } 98 | 99 | // AddTask 添加任务 100 | func (s *BackupScheduler) AddTask(task *entity.BackupTask) error { 101 | s.mutex.Lock() 102 | defer s.mutex.Unlock() 103 | 104 | // 如果任务已存在,先移除 105 | if entryID, exists := s.jobs[task.ID]; exists { 106 | s.cron.Remove(entryID) 107 | delete(s.jobs, task.ID) 108 | } 109 | 110 | // 如果任务未启用,则不添加 111 | if !task.Enabled { 112 | return nil 113 | } 114 | 115 | // 添加任务 116 | entryID, err := s.cron.AddFunc(task.Schedule, func() { 117 | s.executeTask(task.ID) 118 | }) 119 | 120 | if err != nil { 121 | return fmt.Errorf("failed to add task to scheduler: %w", err) 122 | } 123 | 124 | s.jobs[task.ID] = entryID 125 | return nil 126 | } 127 | 128 | // RemoveTask 移除任务 129 | func (s *BackupScheduler) RemoveTask(taskID int64) { 130 | s.mutex.Lock() 131 | defer s.mutex.Unlock() 132 | 133 | if entryID, exists := s.jobs[taskID]; exists { 134 | s.cron.Remove(entryID) 135 | delete(s.jobs, taskID) 136 | } 137 | } 138 | 139 | // IsTaskScheduled 检查任务是否已调度 140 | func (s *BackupScheduler) IsTaskScheduled(taskID int64) bool { 141 | s.mutex.Lock() 142 | defer s.mutex.Unlock() 143 | 144 | _, exists := s.jobs[taskID] 145 | return exists 146 | } 147 | 148 | // GetScheduledTasks 获取所有调度任务ID 149 | func (s *BackupScheduler) GetScheduledTasks() []int64 { 150 | s.mutex.Lock() 151 | defer s.mutex.Unlock() 152 | 153 | var taskIDs []int64 154 | for taskID := range s.jobs { 155 | taskIDs = append(taskIDs, taskID) 156 | } 157 | return taskIDs 158 | } 159 | 160 | // ExecuteTaskNow 立即执行任务 161 | func (s *BackupScheduler) ExecuteTaskNow(taskID int64) error { 162 | go s.executeTask(taskID) 163 | return nil 164 | } 165 | 166 | // 加载所有启用的任务 167 | func (s *BackupScheduler) loadTasks() error { 168 | tasks, err := s.taskRepo.GetEnabledTasks() 169 | if err != nil { 170 | return fmt.Errorf("failed to get enabled tasks: %w", err) 171 | } 172 | 173 | for _, task := range tasks { 174 | // 直接在loadTasks内部处理任务添加,避免调用AddTask方法 175 | // 检查任务是否已存在 176 | s.mutex.Lock() 177 | if entryID, exists := s.jobs[task.ID]; exists { 178 | s.cron.Remove(entryID) 179 | delete(s.jobs, task.ID) 180 | } 181 | 182 | // 如果任务未启用,则不添加 183 | if !task.Enabled { 184 | s.mutex.Unlock() 185 | continue 186 | } 187 | 188 | // 添加任务 189 | entryID, err := s.cron.AddFunc(task.Schedule, func(taskID int64) func() { 190 | return func() { 191 | s.executeTask(taskID) 192 | } 193 | }(task.ID)) 194 | 195 | if err != nil { 196 | log.Printf("添加任务 %d 到调度器失败: %v", task.ID, err) 197 | s.mutex.Unlock() 198 | continue 199 | } 200 | 201 | s.jobs[task.ID] = entryID 202 | s.mutex.Unlock() 203 | } 204 | 205 | return nil 206 | } 207 | 208 | // 执行任务 209 | func (s *BackupScheduler) executeTask(taskID int64) { 210 | log.Printf("开始执行任务 %d", taskID) 211 | 212 | // 获取任务 213 | task, err := s.taskRepo.FindByID(taskID) 214 | if err != nil { 215 | log.Printf("获取任务 %d 失败: %v", taskID, err) 216 | return 217 | } 218 | 219 | if task == nil { 220 | log.Printf("任务 %d 不存在", taskID) 221 | return 222 | } 223 | 224 | // 创建备份服务 225 | backupService, err := backup.NewBackupService(task.Type) 226 | if err != nil { 227 | log.Printf("为任务 %d 创建备份服务失败: %v", taskID, err) 228 | return 229 | } 230 | 231 | // 执行备份 232 | record, err := backupService.Execute(task) 233 | if err != nil { 234 | log.Printf("执行任务 %d 的备份失败: %v", taskID, err) 235 | // 记录已经在备份服务中更新过了 236 | return 237 | } 238 | 239 | log.Printf("任务 %d 执行成功,备份记录ID: %d", taskID, record.ID) 240 | } 241 | 242 | // GetNextExecutionTime 获取任务的下一次执行时间 243 | func (s *BackupScheduler) GetNextExecutionTime(taskID int64) *time.Time { 244 | s.mutex.Lock() 245 | defer s.mutex.Unlock() 246 | 247 | if entryID, exists := s.jobs[taskID]; exists { 248 | entry := s.cron.Entry(entryID) 249 | next := entry.Next 250 | return &next 251 | } 252 | return nil 253 | } 254 | -------------------------------------------------------------------------------- /service/storage/local_storage.go: -------------------------------------------------------------------------------- 1 | package storage 2 | 3 | import ( 4 | "backup-go/entity" 5 | configService "backup-go/service/config" 6 | "fmt" 7 | "io" 8 | "os" 9 | "path/filepath" 10 | "time" 11 | ) 12 | 13 | // LocalStorageService 本地存储服务 14 | type LocalStorageService struct { 15 | basePath string 16 | } 17 | 18 | // NewLocalStorageService 创建本地存储服务 19 | func NewLocalStorageService() *LocalStorageService { 20 | // 从系统配置获取本地存储路径 21 | cs := configService.NewConfigService() 22 | localPath, err := cs.GetConfigValue("storage.localPath") 23 | 24 | // 如果获取失败,则使用默认配置 25 | if err != nil || localPath == "" { 26 | localPath = "./backups" 27 | } 28 | 29 | // 确保目录存在 30 | if err := os.MkdirAll(localPath, 0755); err != nil { 31 | // 使用默认路径 32 | if err := os.MkdirAll("./backups", 0755); err != nil { 33 | fmt.Printf("Failed to create backup directory: %v\n", err) 34 | } 35 | return &LocalStorageService{basePath: "./backups"} 36 | } 37 | return &LocalStorageService{basePath: localPath} 38 | } 39 | 40 | // Save 保存文件 41 | func (s *LocalStorageService) Save(filename string, content io.Reader) (string, error) { 42 | // 创建目录 43 | today := time.Now().Format("20060102") 44 | dirPath := filepath.Join(s.basePath, today) 45 | if err := os.MkdirAll(dirPath, 0755); err != nil { 46 | return "", fmt.Errorf("failed to create directory: %w", err) 47 | } 48 | 49 | // 创建文件路径 50 | filePath := filepath.Join(dirPath, filename) 51 | 52 | // 创建文件 53 | file, err := os.Create(filePath) 54 | if err != nil { 55 | return "", fmt.Errorf("failed to create file: %w", err) 56 | } 57 | defer file.Close() 58 | 59 | // 写入文件 60 | _, err = io.Copy(file, content) 61 | if err != nil { 62 | return "", fmt.Errorf("failed to write file: %w", err) 63 | } 64 | 65 | // 返回相对路径 66 | relativePath, err := filepath.Rel(s.basePath, filePath) 67 | if err != nil { 68 | return filePath, nil 69 | } 70 | return relativePath, nil 71 | } 72 | 73 | // Get 获取文件 74 | func (s *LocalStorageService) Get(path string) (io.ReadCloser, error) { 75 | fullPath := filepath.Join(s.basePath, path) 76 | file, err := os.Open(fullPath) 77 | if err != nil { 78 | return nil, fmt.Errorf("failed to open file: %w", err) 79 | } 80 | return file, nil 81 | } 82 | 83 | // Delete 删除文件 84 | func (s *LocalStorageService) Delete(path string) error { 85 | fullPath := filepath.Join(s.basePath, path) 86 | err := os.Remove(fullPath) 87 | if err != nil { 88 | return fmt.Errorf("failed to delete file: %w", err) 89 | } 90 | return nil 91 | } 92 | 93 | // GetStorageType 获取存储类型 94 | func (s *LocalStorageService) GetStorageType() entity.StorageType { 95 | return entity.LocalStorage 96 | } 97 | -------------------------------------------------------------------------------- /service/storage/s3_storage.go: -------------------------------------------------------------------------------- 1 | package storage 2 | 3 | import ( 4 | "backup-go/entity" 5 | configService "backup-go/service/config" 6 | "fmt" 7 | "io" 8 | "path/filepath" 9 | "time" 10 | 11 | "github.com/aws/aws-sdk-go/aws" 12 | "github.com/aws/aws-sdk-go/aws/credentials" 13 | "github.com/aws/aws-sdk-go/aws/session" 14 | "github.com/aws/aws-sdk-go/service/s3" 15 | "github.com/aws/aws-sdk-go/service/s3/s3manager" 16 | ) 17 | 18 | // S3StorageService S3协议存储服务 19 | type S3StorageService struct { 20 | session *session.Session 21 | uploader *s3manager.Uploader 22 | downloader *s3manager.Downloader 23 | s3Client *s3.S3 24 | bucketName string 25 | } 26 | 27 | // NewS3StorageService 创建S3存储服务 28 | func NewS3StorageService() *S3StorageService { 29 | // 从系统配置表获取配置 30 | cs := configService.NewConfigService() 31 | 32 | // 读取S3配置 33 | s3Endpoint, _ := cs.GetConfigValue("storage.s3Endpoint") 34 | s3Region, _ := cs.GetConfigValue("storage.s3Region") 35 | s3AccessKey, _ := cs.GetConfigValue("storage.s3AccessKey") 36 | s3SecretKey, _ := cs.GetConfigValue("storage.s3SecretKey") 37 | s3Bucket, _ := cs.GetConfigValue("storage.s3Bucket") 38 | 39 | // 如果配置为空,则使用默认值 40 | if s3Endpoint == "" { 41 | s3Endpoint = "" // 默认为空 42 | } 43 | if s3Region == "" { 44 | s3Region = "us-east-1" // 默认区域 45 | } 46 | if s3AccessKey == "" { 47 | s3AccessKey = "" // 默认为空 48 | } 49 | if s3SecretKey == "" { 50 | s3SecretKey = "" // 默认为空 51 | } 52 | if s3Bucket == "" { 53 | s3Bucket = "backup-go" // 默认存储桶 54 | } 55 | 56 | service := &S3StorageService{ 57 | bucketName: s3Bucket, 58 | } 59 | 60 | // 创建AWS会话 61 | sess, err := session.NewSession(&aws.Config{ 62 | Region: aws.String(s3Region), 63 | Endpoint: aws.String(s3Endpoint), 64 | Credentials: credentials.NewStaticCredentials(s3AccessKey, s3SecretKey, ""), 65 | DisableSSL: aws.Bool(false), 66 | S3ForcePathStyle: aws.Bool(true), // 支持非AWS S3服务 67 | }) 68 | if err != nil { 69 | fmt.Printf("Failed to create S3 session: %v\n", err) 70 | return service 71 | } 72 | service.session = sess 73 | 74 | // 创建上传器、下载器和客户端 75 | service.uploader = s3manager.NewUploader(sess) 76 | service.downloader = s3manager.NewDownloader(sess) 77 | service.s3Client = s3.New(sess) 78 | 79 | return service 80 | } 81 | 82 | // Save 保存文件 83 | func (s *S3StorageService) Save(filename string, content io.Reader) (string, error) { 84 | if s.session == nil { 85 | return "", fmt.Errorf("S3 not configured properly") 86 | } 87 | 88 | // 创建目录格式 89 | today := time.Now().Format("20060102") 90 | s3Path := filepath.Join("backups", today, filename) 91 | 92 | // 上传文件 93 | _, err := s.uploader.Upload(&s3manager.UploadInput{ 94 | Bucket: aws.String(s.bucketName), 95 | Key: aws.String(s3Path), 96 | Body: content, 97 | }) 98 | if err != nil { 99 | return "", fmt.Errorf("failed to upload to S3: %w", err) 100 | } 101 | 102 | return s3Path, nil 103 | } 104 | 105 | // Get 获取文件 106 | func (s *S3StorageService) Get(path string) (io.ReadCloser, error) { 107 | if s.session == nil { 108 | return nil, fmt.Errorf("S3 not configured properly") 109 | } 110 | 111 | // 直接使用GetObject方法获取对象 112 | result, err := s.s3Client.GetObject(&s3.GetObjectInput{ 113 | Bucket: aws.String(s.bucketName), 114 | Key: aws.String(path), 115 | }) 116 | if err != nil { 117 | return nil, fmt.Errorf("failed to get file from S3: %w", err) 118 | } 119 | 120 | return result.Body, nil 121 | } 122 | 123 | // Delete 删除文件 124 | func (s *S3StorageService) Delete(path string) error { 125 | if s.session == nil { 126 | return fmt.Errorf("S3 not configured properly") 127 | } 128 | 129 | // 删除文件 130 | _, err := s.s3Client.DeleteObject(&s3.DeleteObjectInput{ 131 | Bucket: aws.String(s.bucketName), 132 | Key: aws.String(path), 133 | }) 134 | if err != nil { 135 | return fmt.Errorf("failed to delete file from S3: %w", err) 136 | } 137 | 138 | return nil 139 | } 140 | 141 | // GetStorageType 获取存储类型 142 | func (s *S3StorageService) GetStorageType() entity.StorageType { 143 | return entity.S3Storage 144 | } 145 | -------------------------------------------------------------------------------- /service/storage/storage.go: -------------------------------------------------------------------------------- 1 | package storage 2 | 3 | import ( 4 | "backup-go/entity" 5 | configService "backup-go/service/config" 6 | "io" 7 | ) 8 | 9 | // StorageService 存储服务接口 10 | type StorageService interface { 11 | // Save 保存文件 12 | Save(filename string, content io.Reader) (string, error) 13 | 14 | // Get 获取文件 15 | Get(path string) (io.ReadCloser, error) 16 | 17 | // Delete 删除文件 18 | Delete(path string) error 19 | 20 | // GetStorageType 获取存储类型 21 | GetStorageType() entity.StorageType 22 | } 23 | 24 | // 存储服务工厂 25 | func NewStorageService(storageType entity.StorageType) (StorageService, error) { 26 | // 如果未指定存储类型,从系统配置表读取 27 | if storageType == "" { 28 | cs := configService.NewConfigService() 29 | storageTypeStr, err := cs.GetConfigValue("storage.type") 30 | if err != nil || storageTypeStr == "" { 31 | // 默认使用本地存储 32 | storageType = entity.LocalStorage 33 | } else { 34 | storageType = entity.StorageType(storageTypeStr) 35 | } 36 | } 37 | 38 | // 根据存储类型创建相应的存储服务 39 | switch storageType { 40 | case entity.LocalStorage: 41 | return NewLocalStorageService(), nil 42 | case entity.S3Storage: 43 | return NewS3StorageService(), nil 44 | default: 45 | return NewLocalStorageService(), nil 46 | } 47 | } 48 | --------------------------------------------------------------------------------