diff --git a/README.md b/README.md index 4ed0eab..18e5227 100644 --- a/README.md +++ b/README.md @@ -641,7 +641,6 @@ table_prefix = baihu_ | `BH_DB_NAME` | database.dbname | 数据库名称 | ql_panel | | `BH_DB_PATH` | database.path | SQLite 文件路径 | ./data/baihu.db | | `BH_DB_TABLE_PREFIX` | database.table_prefix | 表前缀 | baihu_ | -| `BH_SECRET` | security.secret | JWT 密钥 | 手动指定 | ### URL 前缀配置 diff --git a/configs/config.example.ini b/configs/config.example.ini index 6993c7f..a81e66e 100644 --- a/configs/config.example.ini +++ b/configs/config.example.ini @@ -13,8 +13,3 @@ user = root password = dbname = ql_panel table_prefix = baihu_ - - -[security] -secret = baihu_secret_key_change_me - diff --git a/internal/constant/constant.go b/internal/constant/constant.go index 55885d2..77a6728 100644 --- a/internal/constant/constant.go +++ b/internal/constant/constant.go @@ -33,6 +33,7 @@ const ( SectionSite = "site" SectionSystem = "system" SectionScheduler = "scheduler" + SectionSecurity = "security" // Site Settings Key 常量 KeyTitle = "title" @@ -42,6 +43,9 @@ const ( KeyCookieDays = "cookie_days" KeyApiToken = "api_token" + // Security Settings Key 常量 + KeySecret = "secret" + // System Settings Key 常量 KeyInitialized = "initialized" @@ -91,7 +95,7 @@ const ( // TablePrefix 表前缀,从配置文件读取 var TablePrefix string -// Secret JWT和密码salt密钥,从配置文件读取 +// Secret JWT和密码salt密钥,运行中自动从数据库加载 var Secret string // DemoMode 演示模式,从环境变量读取 diff --git a/internal/controllers/task_controller.go b/internal/controllers/task_controller.go index c26bb6b..e821291 100644 --- a/internal/controllers/task_controller.go +++ b/internal/controllers/task_controller.go @@ -63,8 +63,10 @@ func (tc *TaskController) CreateTask(c *gin.Context) { CleanConfig string `json:"clean_config"` Envs string `json:"envs"` Languages []map[string]string `json:"languages"` - AgentID *uint `json:"agent_id"` - TriggerType string `json:"trigger_type"` + AgentID *uint `json:"agent_id"` + TriggerType string `json:"trigger_type"` + RetryCount int `json:"retry_count"` + RetryInterval int `json:"retry_interval"` } if err := c.ShouldBindJSON(&req); err != nil { @@ -91,7 +93,7 @@ func (tc *TaskController) CreateTask(c *gin.Context) { workDir = resolveWorkDir(req.WorkDir) } - task := tc.taskService.CreateTask(req.Name, req.Command, req.Schedule, req.Timeout, workDir, req.CleanConfig, req.Envs, req.Type, req.Config, req.AgentID, req.Languages, req.TriggerType, req.Tags) + task := tc.taskService.CreateTask(req.Name, req.Command, req.Schedule, req.Timeout, workDir, req.CleanConfig, req.Envs, req.Type, req.Config, req.AgentID, req.Languages, req.TriggerType, req.Tags, req.RetryCount, req.RetryInterval) // 如果是 Agent 任务,通知 Agent;否则添加到本地 cron if task.AgentID != nil && *task.AgentID > 0 { @@ -166,8 +168,10 @@ func (tc *TaskController) UpdateTask(c *gin.Context) { Envs string `json:"envs"` Enabled bool `json:"enabled"` Languages []map[string]string `json:"languages"` - AgentID *uint `json:"agent_id"` - TriggerType string `json:"trigger_type"` + AgentID *uint `json:"agent_id"` + TriggerType string `json:"trigger_type"` + RetryCount int `json:"retry_count"` + RetryInterval int `json:"retry_interval"` } if err := c.ShouldBindJSON(&req); err != nil { @@ -188,7 +192,7 @@ func (tc *TaskController) UpdateTask(c *gin.Context) { workDir = resolveWorkDir(req.WorkDir) } - task := tc.taskService.UpdateTask(id, req.Name, req.Command, req.Schedule, req.Timeout, workDir, req.CleanConfig, req.Envs, req.Enabled, req.Type, req.Config, req.AgentID, req.Languages, req.TriggerType, req.Tags) + task := tc.taskService.UpdateTask(id, req.Name, req.Command, req.Schedule, req.Timeout, workDir, req.CleanConfig, req.Envs, req.Enabled, req.Type, req.Config, req.AgentID, req.Languages, req.TriggerType, req.Tags, req.RetryCount, req.RetryInterval) if task == nil { utils.NotFound(c, "任务不存在") return diff --git a/internal/executor/scheduler.go b/internal/executor/scheduler.go index 6d2214a..c9a2ef0 100644 --- a/internal/executor/scheduler.go +++ b/internal/executor/scheduler.go @@ -72,7 +72,13 @@ type ExecutionRequest struct { Timeout int // 超时时间(分钟) Languages []map[string]string // 语言环境配置 UseMise bool // 是否使用 mise - Metadata map[string]interface{} // 额外元数据 + Metadata ExecutionMetadata // 额外元数据 +} + +// ExecutionMetadata 执行额外元数据 +type ExecutionMetadata struct { + GoID int64 // 关联的 goroutine ID + RetryIndex int // 当前重试索引 } // ExecutionResult 执行结果(标准接口) @@ -275,6 +281,21 @@ func (s *Scheduler) EnqueueOrExecute(req *ExecutionRequest) { } } +// EnqueueDelayed 延迟将任务加入队列执行 +func (s *Scheduler) EnqueueDelayed(delay time.Duration, reqBuilder func() *ExecutionRequest) { + go func() { + select { + case <-time.After(delay): + if req := reqBuilder(); req != nil { + s.EnqueueOrExecute(req) + } + case <-s.stopCh: + // 调度器停止时取消延迟投递 + return + } + }() +} + // ExecuteSync 同步执行任务(不经过队列) func (s *Scheduler) ExecuteSync(req *ExecutionRequest) (*ExecutionResult, error) { return s.executeTask(req) diff --git a/internal/models/task.go b/internal/models/task.go index 0ec5c81..4e53018 100644 --- a/internal/models/task.go +++ b/internal/models/task.go @@ -47,14 +47,16 @@ type Task struct { CleanConfig string `json:"clean_config" gorm:"size:255;default:''"` // 清理配置 JSON Envs string `json:"envs" gorm:"size:255;default:''"` // 环境变量ID列表,逗号分隔 Languages []map[string]string `json:"languages" gorm:"serializer:json;type:text"` // 针对本地任务的语言配置列表 - AgentID *uint `json:"agent_id" gorm:"index"` // Agent ID,为空表示本地执行 - Enabled bool `json:"enabled" gorm:"default:true"` - RunningGo string `json:"running_go" gorm:"type:text"` // 正在运行的 go routine id 数组 (JSON) - LastRun *LocalTime `json:"last_run"` - NextRun *LocalTime `json:"next_run"` - CreatedAt LocalTime `json:"created_at"` - UpdatedAt LocalTime `json:"updated_at"` - DeletedAt gorm.DeletedAt `json:"-" gorm:"index"` + AgentID *uint `json:"agent_id" gorm:"index"` // Agent ID,为空表示本地执行 + RetryCount int `json:"retry_count" gorm:"default:0"` // 失败重试次数 + RetryInterval int `json:"retry_interval" gorm:"default:0"` // 失败重试间隔(秒) + Enabled bool `json:"enabled" gorm:"default:true"` + RunningGo string `json:"running_go" gorm:"type:text"` // 正在运行的 go routine id 数组 (JSON) + LastRun *LocalTime `json:"last_run"` + NextRun *LocalTime `json:"next_run"` + CreatedAt LocalTime `json:"created_at"` + UpdatedAt LocalTime `json:"updated_at"` + DeletedAt gorm.DeletedAt `json:"-" gorm:"index"` } func (Task) TableName() string { diff --git a/internal/models/vo/task_vo.go b/internal/models/vo/task_vo.go index 516a5f2..0316d63 100644 --- a/internal/models/vo/task_vo.go +++ b/internal/models/vo/task_vo.go @@ -21,8 +21,10 @@ type TaskVO struct { Envs string `json:"envs"` Languages []map[string]string `json:"languages"` AgentID *uint `json:"agent_id"` - Enabled bool `json:"enabled"` - LastRun *models.LocalTime `json:"last_run"` + Enabled bool `json:"enabled"` + RetryCount int `json:"retry_count"` + RetryInterval int `json:"retry_interval"` + LastRun *models.LocalTime `json:"last_run"` NextRun *models.LocalTime `json:"next_run"` CreatedAt models.LocalTime `json:"created_at"` UpdatedAt models.LocalTime `json:"updated_at"` @@ -47,9 +49,11 @@ func ToTaskVO(task *models.Task) *TaskVO { CleanConfig: task.CleanConfig, Envs: task.Envs, Languages: task.Languages, - AgentID: task.AgentID, - Enabled: task.Enabled, - LastRun: task.LastRun, + AgentID: task.AgentID, + Enabled: task.Enabled, + RetryCount: task.RetryCount, + RetryInterval: task.RetryInterval, + LastRun: task.LastRun, NextRun: task.NextRun, CreatedAt: task.CreatedAt, UpdatedAt: task.UpdatedAt, diff --git a/internal/services/backup_service.go b/internal/services/backup_service.go index d1d1812..be8c81e 100644 --- a/internal/services/backup_service.go +++ b/internal/services/backup_service.go @@ -42,6 +42,7 @@ type tableConfig struct { func (s *BackupService) getTableConfigs() []tableConfig { return []tableConfig{ + {"users.json", s.exportTable(&[]models.User{}, true), s.restoreTable(&[]models.User{}, true)}, {"tasks.json", s.exportTable(&[]models.Task{}, true), s.restoreTable(&[]models.Task{}, true)}, {"task_logs.json", s.exportTable(&[]models.TaskLog{}, false), s.restoreTable(&[]models.TaskLog{}, false)}, {"envs.json", s.exportTable(&[]models.EnvironmentVariable{}, true), s.restoreTable(&[]models.EnvironmentVariable{}, true)}, @@ -199,6 +200,7 @@ func (s *BackupService) Restore(zipPath string) error { // 开启全局事务 return database.DB.Transaction(func(tx *gorm.DB) error { // 1. 清空现有数据(物理删除) + tx.Unscoped().Where("1=1").Delete(&models.User{}) tx.Unscoped().Where("1=1").Delete(&models.Task{}) tx.Unscoped().Where("1=1").Delete(&models.TaskLog{}) tx.Unscoped().Where("1=1").Delete(&models.EnvironmentVariable{}) @@ -281,6 +283,8 @@ func (s *BackupService) restoreFromZipFile(tx *gorm.DB, f *zip.File, filename st } switch filename { + case "users.json": + return restoreStreamBatch[models.User](tx, decoder) case "tasks.json": return restoreStreamBatch[models.Task](tx, decoder) case "task_logs.json": diff --git a/internal/services/config_service.go b/internal/services/config_service.go index 95eee4b..3ff70f8 100644 --- a/internal/services/config_service.go +++ b/internal/services/config_service.go @@ -102,8 +102,8 @@ func LoadConfig(path string) (*AppConfig, error) { // 设置表前缀到 constant 包 constant.TablePrefix = Config.Database.TablePrefix - // 设置 Secret 到 constant 包 - constant.Secret = Config.Security.Secret + // 暂存旧的 Secret,不再直接给 constant 赋值(改为到 settings 初始化时判断) + // constant.Secret = Config.Security.Secret // 设置演示模式 if v := os.Getenv("BH_DEMO_MODE"); v == "true" || v == "1" { diff --git a/internal/services/settings_service.go b/internal/services/settings_service.go index 85330d2..7c07ab9 100644 --- a/internal/services/settings_service.go +++ b/internal/services/settings_service.go @@ -5,6 +5,7 @@ import ( "github.com/engigu/baihu-panel/internal/constant" "github.com/engigu/baihu-panel/internal/database" "github.com/engigu/baihu-panel/internal/models" + "github.com/engigu/baihu-panel/internal/utils" ) type SettingsService struct{} @@ -26,6 +27,25 @@ func (s *SettingsService) InitSettings() error { } } } + // 初始化或获取 JWT Secret 密码 + var secCount int64 + database.DB.Model(&models.Setting{}).Where("section = ? AND `key` = ?", constant.SectionSecurity, constant.KeySecret).Count(&secCount) + var secretValue string + if secCount == 0 { + // 先尝试从配置文件读取遗留下来的旧设 + if Config != nil && Config.Security.Secret != "" { + secretValue = Config.Security.Secret + } else { + secretValue = utils.RandomString(32) + } + if err := database.DB.Create(&models.Setting{Section: constant.SectionSecurity, Key: constant.KeySecret, Value: secretValue}).Error; err != nil { + return err + } + } else { + secretValue = s.Get(constant.SectionSecurity, constant.KeySecret) + } + constant.Secret = secretValue + cache.LoadSiteCache() return nil } diff --git a/internal/services/tasks/executor_service.go b/internal/services/tasks/executor_service.go index 45efab2..d2b5a8d 100644 --- a/internal/services/tasks/executor_service.go +++ b/internal/services/tasks/executor_service.go @@ -141,10 +141,7 @@ func (h *ServerSchedulerHandler) OnTaskExecuting(req *executor.ExecutionRequest) return nil, nil, fmt.Errorf("任务并发限制: %v", err) } - if req.Metadata == nil { - req.Metadata = make(map[string]interface{}) - } - req.Metadata["goid"] = goid + req.Metadata.GoID = goid // 3. 创建 TinyLog 实时日志收集器 tl, err := NewTinyLog(taskLog.ID) @@ -161,6 +158,10 @@ func (h *ServerSchedulerHandler) OnTaskExecuting(req *executor.ExecutionRequest) StartTime: time.Now(), }) + if req.Metadata.RetryIndex > 0 { + tl.Write([]byte(fmt.Sprintf("\n[System] 此为任务失败后的第 %d 次重试执行...\n\n", req.Metadata.RetryIndex))) + } + // 对于本地任务,Scheduler 会通过返回的 Writer 写入日志 // 对于远程任务,Scheduler 不会写入任何内容(由 Agent 推送至此 TL) return tl, tl, nil @@ -235,10 +236,8 @@ func (h *ServerSchedulerHandler) OnTaskCompleted(req *executor.ExecutionRequest, } // 移除运行记录 - if req.Metadata != nil { - if goid, ok := req.Metadata["goid"].(int64); ok { - h.es.RemoveRunningGo(task.ID, goid) - } + if req.Metadata.GoID != 0 { + h.es.RemoveRunningGo(task.ID, req.Metadata.GoID) } // 处理任务完成(更新统计、清理旧日志等) @@ -246,6 +245,9 @@ func (h *ServerSchedulerHandler) OnTaskCompleted(req *executor.ExecutionRequest, // 更新内存缓冲 h.es.UpdateResult(*result) + + // ======= 重试逻辑 ======= + h.es.HandleTaskRetry(task, req, result.Success, result.Status, result.ExitCode) } func (h *ServerSchedulerHandler) OnTaskFailed(req *executor.ExecutionRequest, err error) { @@ -257,10 +259,8 @@ func (h *ServerSchedulerHandler) OnTaskFailed(req *executor.ExecutionRequest, er fmt.Sscanf(req.TaskID, "%d", &taskID) // 移除运行记录 - if req.Metadata != nil { - if goid, ok := req.Metadata["goid"].(int64); ok { - h.es.RemoveRunningGo(taskID, goid) - } + if req.Metadata.GoID != 0 { + h.es.RemoveRunningGo(taskID, req.Metadata.GoID) } // 构造错误日志 @@ -305,6 +305,48 @@ func (h *ServerSchedulerHandler) OnTaskFailed(req *executor.ExecutionRequest, er StartTime: time.Now(), EndTime: time.Now(), }) + + // ======= 重试逻辑 ======= + h.es.HandleTaskRetry(task, req, false, constant.TaskStatusFailed, 1) +} + +// HandleTaskRetry 处理任务失败重试逻辑 +func (es *ExecutorService) HandleTaskRetry(task *models.Task, req *executor.ExecutionRequest, isSuccess bool, status string, exitCode int) { + if task == nil { + return + } + + if !isSuccess || status == constant.TaskStatusFailed || status == constant.TaskStatusTimeout || exitCode != 0 { + retryIndex := req.Metadata.RetryIndex + + if retryIndex < task.RetryCount { + retryIndex++ + logger.Infof("[Executor] 任务 #%d 执行失败/出错,将在 %d 秒后进行第 %d/%d 次重试...", task.ID, task.RetryInterval, retryIndex, task.RetryCount) + + es.scheduler.EnqueueDelayed(time.Duration(task.RetryInterval)*time.Second, func() *executor.ExecutionRequest { + latestTask := es.taskService.GetTaskByID(int(task.ID)) + if latestTask == nil || !latestTask.Enabled { + return nil + } + + newEnvs := es.loadEnvVars(latestTask.Envs) + return &executor.ExecutionRequest{ + TaskID: req.TaskID, + Name: latestTask.Name, + Command: latestTask.Command, + WorkDir: latestTask.WorkDir, + Envs: newEnvs, + Timeout: latestTask.Timeout, + Languages: latestTask.Languages, + UseMise: latestTask.UseMise(), + Type: executor.TaskTypeManual, + Metadata: executor.ExecutionMetadata{ + RetryIndex: retryIndex, + }, + } + }) + } + } } func (h *ServerSchedulerHandler) OnCronNextRun(req *executor.ExecutionRequest, nextRun time.Time) { diff --git a/internal/services/tasks/task_service.go b/internal/services/tasks/task_service.go index 4c2791f..cb647f0 100644 --- a/internal/services/tasks/task_service.go +++ b/internal/services/tasks/task_service.go @@ -12,7 +12,7 @@ func NewTaskService() *TaskService { return &TaskService{} } -func (ts *TaskService) CreateTask(name, command, schedule string, timeout int, workDir, cleanConfig, envs, taskType, config string, agentID *uint, languages []map[string]string, triggerType string, tags string) *models.Task { +func (ts *TaskService) CreateTask(name, command, schedule string, timeout int, workDir, cleanConfig, envs, taskType, config string, agentID *uint, languages []map[string]string, triggerType string, tags string, retryCount int, retryInterval int) *models.Task { if taskType == "" { taskType = "task" } @@ -31,9 +31,11 @@ func (ts *TaskService) CreateTask(name, command, schedule string, timeout int, w WorkDir: workDir, CleanConfig: cleanConfig, Envs: envs, - Languages: languages, - AgentID: agentID, - Enabled: true, + Languages: languages, + AgentID: agentID, + Enabled: true, + RetryCount: retryCount, + RetryInterval: retryInterval, } if triggerType != constant.TriggerTypeCron { task.NextRun = nil @@ -81,7 +83,7 @@ func (ts *TaskService) GetTaskByID(id int) *models.Task { return &task } -func (ts *TaskService) UpdateTask(id int, name, command, schedule string, timeout int, workDir, cleanConfig, envs string, enabled bool, taskType, config string, agentID *uint, languages []map[string]string, triggerType string, tags string) *models.Task { +func (ts *TaskService) UpdateTask(id int, name, command, schedule string, timeout int, workDir, cleanConfig, envs string, enabled bool, taskType, config string, agentID *uint, languages []map[string]string, triggerType string, tags string, retryCount int, retryInterval int) *models.Task { var task models.Task if err := database.DB.First(&task, id).Error; err != nil { return nil @@ -97,6 +99,8 @@ func (ts *TaskService) UpdateTask(id int, name, command, schedule string, timeou task.Enabled = enabled task.AgentID = agentID task.Languages = languages + task.RetryCount = retryCount + task.RetryInterval = retryInterval if taskType != "" { task.Type = taskType } diff --git a/web/src/api/index.ts b/web/src/api/index.ts index 99c88c0..126ff9d 100644 --- a/web/src/api/index.ts +++ b/web/src/api/index.ts @@ -284,6 +284,8 @@ export interface Task { work_dir: string clean_config: string envs: string + retry_count: number + retry_interval: number languages: { name: string; version: string }[] agent_id: number | null enabled: boolean diff --git a/web/src/views/tasks/RepoDialog.vue b/web/src/views/tasks/RepoDialog.vue index 859bcf2..c86a464 100644 --- a/web/src/views/tasks/RepoDialog.vue +++ b/web/src/views/tasks/RepoDialog.vue @@ -349,6 +349,20 @@ async function save() { +
+ +
+
+ + +
+
+ 间隔 + + +
+
+
diff --git a/web/src/views/tasks/TaskDialog.vue b/web/src/views/tasks/TaskDialog.vue index df90955..46f47be 100644 --- a/web/src/views/tasks/TaskDialog.vue +++ b/web/src/views/tasks/TaskDialog.vue @@ -605,6 +605,20 @@ async function save() {

如果任务未执行完成,是否允许再次执行

+
+ +
+
+ + +
+
+ 间隔 + + +
+
+