feat: add qlrepo sync crontab

This commit is contained in:
engigu
2026-03-19 11:06:45 +08:00
parent a03800f196
commit cd818f7b0c
23 changed files with 4126 additions and 529 deletions
+37 -8
View File
@@ -7,6 +7,7 @@ import (
"encoding/json"
"fmt"
"net/http"
"os"
"strings"
"time"
@@ -301,18 +302,46 @@ func (sc *SettingsController) GetAbout(c *gin.Context) {
// 运行时间
uptime := formatDuration(time.Since(constant.StartTime))
// 获取远程最新版本
remoteVersion := ""
client := &http.Client{Timeout: 2 * time.Second}
req, err := http.NewRequest("GET", "https://api.github.com/repos/engigu/baihu-panel/releases/latest", nil)
if err == nil {
req.Header.Set("User-Agent", "baihu-panel")
if resp, err := client.Do(req); err == nil {
defer resp.Body.Close()
var release struct {
TagName string `json:"tag_name"`
}
if err := json.NewDecoder(resp.Body).Decode(&release); err == nil {
remoteVersion = release.TagName
}
}
}
utils.Success(c, gin.H{
"version": constant.Version,
"build_time": constant.BuildTime,
"mem_usage": memUsage,
"goroutines": runtime.NumGoroutine(),
"uptime": uptime,
"task_count": taskCount,
"log_count": logCount,
"env_count": envCount,
"version": constant.Version,
"remote_version": remoteVersion,
"build_time": constant.BuildTime,
"mem_usage": memUsage,
"goroutines": runtime.NumGoroutine(),
"uptime": uptime,
"task_count": taskCount,
"log_count": logCount,
"env_count": envCount,
})
}
// GetChangelog 获取更新日志
func (sc *SettingsController) GetChangelog(c *gin.Context) {
content, err := os.ReadFile("docs/guide/changelog.md")
if err != nil {
utils.Success(c, "暂无更新日志")
return
}
utils.Success(c, string(content))
}
// formatBytes 格式化字节数
func formatBytes(bytes uint64) string {
const unit = 1024
+128 -2
View File
@@ -1,9 +1,11 @@
package controllers
import (
"encoding/json"
"path/filepath"
"github.com/engigu/baihu-panel/internal/constant"
"github.com/engigu/baihu-panel/internal/models"
"github.com/engigu/baihu-panel/internal/models/vo"
"github.com/engigu/baihu-panel/internal/services"
"github.com/engigu/baihu-panel/internal/services/tasks"
@@ -93,7 +95,30 @@ 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, req.RetryCount, req.RetryInterval, req.RandomRange)
var sourceID string
// 如果是仓库同步任务,根据 URL 生成 SourceID 用于去重
if req.Type == constant.TaskTypeRepo && req.Config != "" {
var repoCfg struct {
SourceURL string `json:"source_url"`
Branch string `json:"branch"`
}
if err := json.Unmarshal([]byte(req.Config), &repoCfg); err == nil && repoCfg.SourceURL != "" {
sourceID = "repo_" + utils.GetRepoIdentifier(repoCfg.SourceURL, repoCfg.Branch)
}
}
var task *models.Task
// 去重逻辑:如果已存在相同 SourceID 的仓库任务,则改为更新
if sourceID != "" {
task = tc.taskService.GetTaskBySourceID(sourceID)
if task != nil {
task = tc.taskService.UpdateTask(task.ID, req.Name, req.Command, req.Schedule, req.Timeout, workDir, req.CleanConfig, req.Envs, true, req.Type, req.Config, req.AgentID, req.Languages, req.TriggerType, req.Tags, req.RetryCount, req.RetryInterval, req.RandomRange, sourceID)
}
}
if task == nil {
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, req.RandomRange, sourceID)
}
// 如果是 Agent 任务,通知 Agent;否则添加到本地 cron
if task.AgentID != nil && *task.AgentID != "" {
@@ -228,7 +253,20 @@ 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, req.RetryCount, req.RetryInterval, req.RandomRange)
var sourceID string
if req.Type == constant.TaskTypeRepo && req.Config != "" {
var repoCfg struct {
SourceURL string `json:"source_url"`
Branch string `json:"branch"`
}
if err := json.Unmarshal([]byte(req.Config), &repoCfg); err == nil && repoCfg.SourceURL != "" {
sourceID = "repo_" + utils.GetRepoIdentifier(repoCfg.SourceURL, repoCfg.Branch)
}
} else if oldTask != nil {
sourceID = oldTask.SourceID
}
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, req.RandomRange, sourceID)
if task == nil {
utils.NotFound(c, "任务不存在")
return
@@ -300,6 +338,94 @@ func (tc *TaskController) DeleteTask(c *gin.Context) {
utils.SuccessMsg(c, "删除成功")
}
func (tc *TaskController) BatchDeleteTasks(c *gin.Context) {
var req struct {
IDs []string `json:"ids" binding:"required"`
}
if err := c.ShouldBindJSON(&req); err != nil {
utils.BadRequest(c, err.Error())
return
}
// 收集涉及到的 AgentID
agentIDs := make(map[string]struct{})
for _, id := range req.IDs {
// 获取任务信息
task := tc.taskService.GetTaskByID(id)
if task != nil {
if task.AgentID != nil && *task.AgentID != "" {
agentIDs[*task.AgentID] = struct{}{}
}
}
// 移除 cron 调度
tc.executorService.RemoveCronTask(id)
}
// 执行批量删除
count := tc.taskService.BatchDeleteTasks(req.IDs)
// 通知受影响的 Agent
for agentID := range agentIDs {
tc.agentWSManager.BroadcastTasks(agentID)
}
utils.Success(c, gin.H{"count": count})
}
// BatchDeleteByQuery 根据查询条件批量删除任务
// @Summary 根据查询条件批量删除任务
// @Description 根据查询条件批量删除匹配的所有任务
// @Tags 任务管理
// @Accept json
// @Produce json
// @Security BearerAuth
// @Param name query string false "任务名称关键词"
// @Param tags query string false "标签关键词"
// @Param type query string false "任务类型"
// @Param agent_id query string false "执行位置(节点ID)"
// @Success 200 {object} utils.Response{data=map[string]int}
// @Failure 401 {object} utils.Response "未授权"
// @Router /tasks/batch-by-query [delete]
func (tc *TaskController) BatchDeleteByQuery(c *gin.Context) {
name := c.Query("name")
agentIDStr := c.Query("agent_id")
tags := c.Query("tags")
taskType := c.Query("type")
var agentID *string
if agentIDStr != "" {
agentID = &agentIDStr
}
tasks, _ := tc.taskService.GetTasksWithPagination(1, 999999, name, agentID, tags, taskType)
if len(tasks) == 0 {
utils.Success(c, gin.H{"count": 0})
return
}
var ids []string
agentIDs := make(map[string]struct{})
for _, task := range tasks {
ids = append(ids, task.ID)
if task.AgentID != nil && *task.AgentID != "" {
agentIDs[*task.AgentID] = struct{}{}
}
// 移除 cron 调度
tc.executorService.RemoveCronTask(task.ID)
}
// 执行批量删除
count := tc.taskService.BatchDeleteTasks(ids)
// 通知受影响的 Agent
for aID := range agentIDs {
tc.agentWSManager.BroadcastTasks(aID)
}
utils.Success(c, gin.H{"count": count})
}
// StopTask 停止任务
// @Summary 停止任务
// @Description 根据运行日志 ID 停止正在执行的任务
+8 -1
View File
@@ -23,7 +23,12 @@ type RepoConfig struct {
Proxy string `json:"proxy"` // 代理类型: none, ghproxy, mirror, custom
ProxyURL string `json:"proxy_url"` // 自定义代理地址
AuthToken string `json:"auth_token"` // 认证 Token
WhitelistPaths string `json:"whitelist_paths"` // 同步时保留的路径(白名单路径),逗号分隔
WhitelistPaths string `json:"whitelist_paths"` // 同步时保留的路径及脚本筛选白名单关键词,逗号或竖线分割
Blacklist string `json:"blacklist"` // 脚本筛选黑名单关键词,竖线分割
Dependence string `json:"dependence"` // 脚本依赖文件关键词,竖线分割
Extensions string `json:"extensions"` // 脚本文件后缀关键词,竖线分割
AutoAddCron bool `json:"auto_add_cron"` // 自动解析脚本注释添加定时任务
RepoSource string `json:"repo_source"` // 仓库来源,如果是选择了这个 ql 导入的仓库,= ql
}
// TaskConfig 任务配置 RepoConfig+TaskConfig=task.config
@@ -56,6 +61,8 @@ type Task struct {
RuntimeEnvs []string `json:"-" gorm:"-"` // 运行时环境变量(非持久化)
LastRun *LocalTime `json:"last_run"`
NextRun *LocalTime `json:"next_run"`
SourceID string `json:"source_id" gorm:"size:255;index"` // 脚本资源唯一标识(路径 sanitized)
RepoTaskID string `json:"repo_task_id" gorm:"size:20;index"` // 所属的仓库任务 ID
CreatedAt LocalTime `json:"created_at"`
UpdatedAt LocalTime `json:"updated_at"`
DeletedAt gorm.DeletedAt `json:"-" gorm:"index"`
+2
View File
@@ -21,6 +21,7 @@ type TaskVO struct {
Envs string `json:"envs"`
Languages []map[string]string `json:"languages"`
AgentID *string `json:"agent_id"`
RepoTaskID string `json:"repo_task_id"`
Enabled bool `json:"enabled"`
RetryCount int `json:"retry_count"`
RetryInterval int `json:"retry_interval"`
@@ -51,6 +52,7 @@ func ToTaskVO(task *models.Task) *TaskVO {
Envs: string(task.Envs),
Languages: task.Languages,
AgentID: task.AgentID,
RepoTaskID: task.RepoTaskID,
Enabled: task.Enabled,
RetryCount: task.RetryCount,
RetryInterval: task.RetryInterval,
+3
View File
@@ -73,6 +73,8 @@ func registerTaskRoutes(g *gin.RouterGroup, c *Controllers) {
tasks.GET("/:id", c.Task.GetTask)
tasks.PUT("/:id", c.Task.UpdateTask)
tasks.DELETE("/:id", c.Task.DeleteTask)
tasks.POST("/batch-delete", c.Task.BatchDeleteTasks)
tasks.DELETE("/batch-by-query", c.Task.BatchDeleteByQuery)
tasks.POST("/stop/:logID", c.Task.StopTask)
}
@@ -153,6 +155,7 @@ func registerSettingsRoutes(g *gin.RouterGroup, c *Controllers) {
settings.GET("/scheduler", c.Settings.GetSchedulerSettings)
settings.PUT("/scheduler", c.Settings.UpdateSchedulerSettings)
settings.GET("/about", c.Settings.GetAbout)
settings.GET("/changelog", c.Settings.GetChangelog)
settings.GET("/loginlogs", c.Settings.GetLoginLogs)
settings.POST("/backup", c.Settings.CreateBackup)
settings.GET("/backup/status", c.Settings.GetBackupStatus)
+31 -2
View File
@@ -241,6 +241,10 @@ func (h *ServerSchedulerHandler) OnTaskCompleted(req *executor.ExecutionRequest,
// 处理任务完成(更新统计、清理旧日志等)
h.es.taskLogService.ProcessTaskCompletion(taskLog)
if task.Type == constant.TaskTypeRepo && result.Status == constant.TaskStatusSuccess {
go ParseRepoScriptsAndAddCron(h.es, task)
}
// 更新内存缓冲
h.es.UpdateResult(*result)
@@ -418,6 +422,10 @@ func (h *LocalTaskHooks) OnHeartbeat(ctx context.Context, logID string, duration
// ExecuteDispatcher 实现任务分发逻辑
func (es *ExecutorService) ExecuteDispatcher(ctx context.Context, req *executor.ExecutionRequest, stdout, stderr io.Writer) (*executor.Result, error) {
taskID := req.TaskID
// 解析路径变量 (如 $SCRIPTS_DIR$)
req.Command = es.ResolvePath(req.Command)
req.WorkDir = es.ResolvePath(req.WorkDir)
task := es.taskService.GetTaskByID(taskID)
// 系统任务(无 taskID)直接本地执行
@@ -479,7 +487,7 @@ func (es *ExecutorService) Stop() {
// StartCron 启动计划任务
func (es *ExecutorService) StartCron() {
es.loadCronTasks()
go es.loadCronTasks()
es.cronManager.Start()
// logger.Info("[Executor] 计划任务管理器已启动")
}
@@ -949,8 +957,24 @@ func (es *ExecutorService) BuildRepoCommand(task *models.Task) (string, string)
if config.WhitelistPaths != "" {
args = append(args, "--whitelist-paths", config.WhitelistPaths)
}
if config.Blacklist != "" {
args = append(args, "--blacklist", config.Blacklist)
}
if config.Dependence != "" {
args = append(args, "--dependence", config.Dependence)
}
if config.Extensions != "" {
args = append(args, "--extensions", config.Extensions)
}
return exePath + " " + strings.Join(args, " "), filepath.Dir(exePath)
// 为了防止 shell 解释特殊字符(如 |),对每个参数进行转义/加引号
quotedArgs := make([]string, len(args))
for i, arg := range args {
// 使用单引号包裹参数,并转义已有的单引号
quotedArgs[i] = "'" + strings.ReplaceAll(arg, "'", "'\\''") + "'"
}
return "'" + strings.ReplaceAll(exePath, "'", "'\\''") + "' " + strings.Join(quotedArgs, " "), filepath.Dir(exePath)
}
// loadEnvVars 加载环境变量,支持全局注入及重名合并
@@ -981,3 +1005,8 @@ func (es *ExecutorService) loadEnvVars(taskID string, envIDs string) []string {
return nil
}
func (es *ExecutorService) ResolvePath(path string) string {
absScriptsDir, _ := filepath.Abs(constant.ScriptsWorkDir)
return strings.ReplaceAll(path, "$SCRIPTS_DIR$", absScriptsDir)
}
+427
View File
@@ -0,0 +1,427 @@
package tasks
import (
"bufio"
"encoding/json"
"fmt"
"io/fs"
"os"
"path/filepath"
"regexp"
"strings"
"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"
)
// regex patterns for script comment parsing
var (
envRegex = regexp.MustCompile(`(?i)[new ]*Env\(['"]?([^'"]+)['"]?\)[;]?`)
cronRegex = regexp.MustCompile(`(?i)(?:cron[ \t]*[:=]?[ \t]*['"]([^'"]+)['"])|(?:(?:^|[ \t\*\/])([0-9\*\/\-,L?]+[ \t]+[0-9\*\/\-,L?#]+[ \t]+[0-9\*\/\-,L?#]+[ \t]+[0-9\*\/\-,L?#]+[ \t]+[0-9\*\/\-,L?#]+(?:[ \t]+[0-9\*\/\-,L?#]+)?))`)
)
// ParseRepoScriptsAndAddCron scans the repo dir for scripts, parses cron and env comments, and registers tasks
func ParseRepoScriptsAndAddCron(es *ExecutorService, repoTask *models.Task) {
if repoTask == nil || repoTask.Type != constant.TaskTypeRepo {
return
}
var repoCfg models.RepoConfig
if err := json.Unmarshal([]byte(repoTask.Config), &repoCfg); err != nil {
return
}
if repoCfg.RepoSource != "ql" || !repoCfg.AutoAddCron {
return
}
// target path
targetPath := repoCfg.TargetPath
if targetPath == "" {
targetPath = repoTask.WorkDir
}
if targetPath == "" {
return
}
// We might have appended a repo id to targetPath
repoId := utils.GetRepoIdentifier(repoCfg.SourceURL, repoCfg.Branch)
gitDir := filepath.Join(targetPath, ".git")
if !isDir(targetPath) || !pathExists(gitDir) {
repoPath := filepath.Join(targetPath, repoId)
if pathExists(repoPath) {
targetPath = repoPath
}
}
if !pathExists(targetPath) {
return
}
// tag used during sync
tag := fmt.Sprintf("%s", repoId)
exts := []string{".js", ".py", ".ts", ".sh", ".php"}
if repoCfg.Extensions != "" {
customExts := splitKeywords(repoCfg.Extensions)
if len(customExts) > 0 {
exts = nil
for _, e := range customExts {
e = strings.TrimSpace(e)
if e != "" {
if !strings.HasPrefix(e, ".") {
e = "." + e
}
exts = append(exts, e)
}
}
}
}
foundSourceIDs := make(map[string]bool)
filepath.WalkDir(targetPath, func(path string, d fs.DirEntry, err error) error {
if err != nil || d.IsDir() {
return nil
}
if strings.Contains(path, ".git") {
return nil
}
ext := filepath.Ext(path)
validExt := false
for _, e := range exts {
if ext == e {
validExt = true
break
}
}
if !validExt {
return nil
}
f, err := os.Open(path)
if err != nil {
return nil
}
defer f.Close()
var taskName string
var taskCron string
scanner := bufio.NewScanner(f)
var firstCommentLine string
inBlockComment := false
// 特殊处理:针对当前文件名的 Cron 关联正则表达式 (对标青龙 perl 逻辑)
// 寻找类似 "// 0 0 * * * jd_task.js" 的行
fileNameEscaped := regexp.QuoteMeta(filepath.Base(path))
associatedCronRegex := regexp.MustCompile(fmt.Sprintf(`(?i)(?:^|[ \t\*\//])(([0-9\*\/\-,L?#]+[ \t]+){4,5}[0-9\*\/\-,L?#]+)[ \t,"]+.*%s`, fileNameEscaped))
for i := 0; i < 150 && scanner.Scan(); i++ { // QL 扫描范围较大
line := strings.TrimSpace(scanner.Text())
if line == "" {
continue
}
// 处理块注释开始/结束
if strings.HasPrefix(line, "/*") {
inBlockComment = true
line = strings.TrimPrefix(line, "/*")
line = strings.TrimPrefix(line, "*")
line = strings.TrimSpace(line)
}
if strings.HasSuffix(line, "*/") {
inBlockComment = false
line = strings.TrimSuffix(line, "*/")
line = strings.TrimSpace(line)
}
// 1. 尝试提取任务名称 (优先使用 Env)
if taskName == "" {
if envMatch := envRegex.FindStringSubmatch(line); len(envMatch) > 1 {
taskName = strings.TrimSpace(envMatch[1])
} else if strings.Contains(line, "name:") {
// 兼容 name: "xxx" 格式
nameRegex := regexp.MustCompile(`(?i)name:[ \t]*['"]([^'"]+)['"]`)
if nameMatch := nameRegex.FindStringSubmatch(line); len(nameMatch) > 1 {
taskName = strings.TrimSpace(nameMatch[1])
}
}
}
// 如果还没找到名称,且在注释中,记录第一行非空注释作为备选名称
if taskName == "" && (inBlockComment || strings.HasPrefix(line, "//") || strings.HasPrefix(line, "*") || strings.HasPrefix(line, "#")) {
cleanLine := line
if strings.HasPrefix(line, "//") {
cleanLine = strings.TrimPrefix(line, "//")
} else if strings.HasPrefix(line, "#") {
cleanLine = strings.TrimPrefix(line, "#")
} else if strings.HasPrefix(line, "*") {
cleanLine = strings.TrimPrefix(line, "*")
}
cleanLine = strings.TrimSpace(cleanLine)
// 排除掉包含 "Env" 或 "cron" 的行, 且排除掉可能是路径或URL的行
if cleanLine != "" && !strings.Contains(strings.ToLower(cleanLine), "env") &&
!strings.Contains(strings.ToLower(cleanLine), "cron") &&
!strings.Contains(cleanLine, "http") &&
!strings.Contains(cleanLine, "/") &&
firstCommentLine == "" {
// 且排除掉纯 cron 表达式
if !cronRegex.MatchString(cleanLine) {
firstCommentLine = cleanLine
}
}
}
// 2. 提取 Cron
if taskCron == "" {
// A. 优先查找关联了当前文件名的 Cron (对标 QL)
if assocMatch := associatedCronRegex.FindStringSubmatch(line); len(assocMatch) > 1 {
taskCron = strings.TrimSpace(assocMatch[1])
}
// B. 如果没找到,尝试普通的 cron: "..." 或 cron 表达式
if taskCron == "" {
if cronMatch := cronRegex.FindStringSubmatch(line); len(cronMatch) > 0 {
for _, m := range cronMatch[1:] {
if m != "" {
taskCron = strings.TrimSpace(m)
break
}
}
}
}
}
if taskName != "" && taskCron != "" {
break
}
}
// 如果最后还是没找到 taskName,使用备选名称或文件名
if taskName == "" {
if firstCommentLine != "" {
taskName = firstCommentLine
} else {
taskName = strings.TrimSuffix(filepath.Base(path), ext)
}
}
// 3. 应用白名单 / 黑名单过滤 (逻辑对标青龙)
// relRepoPath 是相对于仓库根目录的路径,filename 是文件名
relRepoPath, _ := filepath.Rel(targetPath, path)
filename := filepath.Base(path)
// 只有在显式设置了白名单时才进行白名单校验 (青龙行为)
if repoCfg.WhitelistPaths != "" {
if !matchesQLPattern(relRepoPath, filename, repoCfg.WhitelistPaths) {
return nil
}
}
// 校验黑名单
if repoCfg.Blacklist != "" {
if matchesQLPattern(relRepoPath, filename, repoCfg.Blacklist) {
return nil
}
}
if taskName != "" && taskCron != "" {
// 获取脚本相对于数据目录的路径
absScriptsDir, _ := filepath.Abs(constant.ScriptsWorkDir)
absPath, _ := filepath.Abs(path)
// 计算 SourceID: 相对于脚本目录的完整路径,并清洗特殊符号
relPath, _ := filepath.Rel(absScriptsDir, absPath)
sourceID := sanitizeIdentifier(relPath)
// 替换绝对路径为代号 $SCRIPTS_DIR$
displayPath := path
displayWorkDir := targetPath
if strings.HasPrefix(absPath, absScriptsDir) {
displayPath = filepath.Join("$SCRIPTS_DIR$", relPath)
// 获取目录路径
relDir, _ := filepath.Rel(absScriptsDir, targetPath)
displayWorkDir = filepath.Join("$SCRIPTS_DIR$", relDir)
}
// Found task, save it
command := getCommandByExt(ext, displayPath)
// 注册任务默认开启“全量环境变量注入”,以适配大多数脚本
defaultTaskConfig := `{"$task_all_envs":true}`
// See if task exists (优先通过 SourceID 匹配)
var existing models.Task
err := database.DB.Where("source_id = ?", sourceID).First(&existing).Error
if err != nil {
// 降级使用 command + tag 匹配 (兼容旧数据)
err = database.DB.Where("command = ? AND tags LIKE ?", command, "%"+tag+"%").First(&existing).Error
}
if err == nil {
// update
existing.Name = taskName
existing.Schedule = normalizeCron(taskCron)
existing.Languages = repoTask.Languages
existing.SourceID = sourceID
existing.RepoTaskID = repoTask.ID
existing.WorkDir = displayWorkDir
// 如果原配置为空或者是 {},则应用默认配置
if string(existing.Config) == "" || string(existing.Config) == "{}" {
existing.Config = models.BigText(defaultTaskConfig)
}
database.DB.Save(&existing)
if existing.Enabled && es != nil {
es.AddCronTask(&existing)
}
foundSourceIDs[sourceID] = true
} else {
// create new
newTask := &models.Task{
Name: taskName,
Command: models.BigText(command),
Schedule: normalizeCron(taskCron),
Type: "task",
TriggerType: constant.TriggerTypeCron,
Tags: tag,
Languages: repoTask.Languages,
Timeout: repoTask.Timeout,
Config: models.BigText(defaultTaskConfig),
Enabled: true,
WorkDir: displayWorkDir,
SourceID: sourceID,
RepoTaskID: repoTask.ID,
}
newTask.ID = utils.GenerateID()
database.DB.Create(newTask)
if es != nil {
es.AddCronTask(newTask)
}
foundSourceIDs[sourceID] = true
}
}
return nil
})
// 清理该仓库任务下不再存在的旧脚本任务
var oldTasks []models.Task
if err := database.DB.Where("repo_task_id = ?", repoTask.ID).Find(&oldTasks).Error; err == nil {
for _, ot := range oldTasks {
if !foundSourceIDs[ot.SourceID] {
if es != nil {
if es.taskService != nil {
es.taskService.DeleteTask(ot.ID)
}
es.RemoveCronTask(ot.ID)
} else {
// Fallback if es is nil (which shouldn't happen, but just in case)
database.DB.Unscoped().Where("id = ?", ot.ID).Delete(&models.Task{})
}
}
}
}
}
func sanitizeIdentifier(s string) string {
// 将所有非字母数字替换为下划线
reg := regexp.MustCompile(`[^a-zA-Z0-9]+`)
res := reg.ReplaceAllString(s, "_")
return strings.ToLower(strings.Trim(res, "_"))
}
func normalizeCron(cron string) string {
fields := strings.Fields(cron)
if len(fields) == 5 {
return "0 " + cron
}
return cron
}
func pathExists(path string) bool {
_, err := os.Stat(path)
return err == nil
}
func isDir(path string) bool {
info, err := os.Stat(path)
if err != nil {
return false
}
return info.IsDir()
}
func getCommandByExt(ext, path string) string {
switch ext {
case ".js", ".ts":
return fmt.Sprintf("node %s", path)
case ".py":
return fmt.Sprintf("python %s", path)
case ".sh":
return fmt.Sprintf("bash %s", path)
case ".php":
return fmt.Sprintf("php %s", path)
}
return path
}
func matchesQLPattern(rel, filename string, keywordsStr string) bool {
if keywordsStr == "" {
return false
}
keywords := splitKeywords(keywordsStr)
for _, k := range keywords {
// 1. 尝试作为正则整体进行匹配,默认不区分大小写 (?i)
pattern := k
if !strings.HasPrefix(pattern, "(?i)") {
pattern = "(?i)" + pattern
}
reg, err := regexp.Compile(pattern)
if err == nil {
// 优先匹配文件名(解决 ^jd[^_] 这种锚点在相对路径下失效的问题)
if reg.MatchString(filename) || reg.MatchString(rel) {
return true
}
} else {
// 回退逻辑:全小写包含判断
kLower := strings.ToLower(k)
if strings.Contains(strings.ToLower(rel), kLower) || strings.Contains(strings.ToLower(filename), kLower) {
return true
}
}
}
return false
}
func splitKeywords(s string) []string {
if s == "" {
return nil
}
var parts []string
if strings.Contains(s, "|") {
parts = strings.Split(s, "|")
} else if strings.Contains(s, ",") {
parts = strings.Split(s, ",")
} else {
parts = []string{s}
}
var res []string
for _, p := range parts {
p = strings.TrimSpace(p)
if p != "" {
res = append(res, p)
}
}
return res
}
+23 -3
View File
@@ -13,7 +13,15 @@ func NewTaskService() *TaskService {
return &TaskService{}
}
func (ts *TaskService) CreateTask(name, command, schedule string, timeout int, workDir, cleanConfig, envs, taskType, config string, agentID *string, languages []map[string]string, triggerType string, tags string, retryCount int, retryInterval int, randomRange int) *models.Task {
func (ts *TaskService) GetTaskBySourceID(sourceID string) *models.Task {
var task models.Task
if err := database.DB.Where("source_id = ?", sourceID).First(&task).Error; err != nil {
return nil
}
return &task
}
func (ts *TaskService) CreateTask(name, command, schedule string, timeout int, workDir, cleanConfig, envs, taskType, config string, agentID *string, languages []map[string]string, triggerType string, tags string, retryCount int, retryInterval int, randomRange int, sourceID string) *models.Task {
if taskType == "" {
taskType = "task"
}
@@ -39,6 +47,7 @@ func (ts *TaskService) CreateTask(name, command, schedule string, timeout int, w
RetryCount: retryCount,
RetryInterval: retryInterval,
RandomRange: randomRange,
SourceID: sourceID,
CreatedAt: models.Now(),
UpdatedAt: models.Now(),
}
@@ -88,7 +97,7 @@ func (ts *TaskService) GetTaskByID(id string) *models.Task {
return &task
}
func (ts *TaskService) UpdateTask(id string, name, command, schedule string, timeout int, workDir, cleanConfig, envs string, enabled bool, taskType, config string, agentID *string, languages []map[string]string, triggerType string, tags string, retryCount int, retryInterval int, randomRange int) *models.Task {
func (ts *TaskService) UpdateTask(id string, name, command, schedule string, timeout int, workDir, cleanConfig, envs string, enabled bool, taskType, config string, agentID *string, languages []map[string]string, triggerType string, tags string, retryCount int, retryInterval int, randomRange int, sourceID string) *models.Task {
var task models.Task
if err := database.DB.Where("id = ?", id).First(&task).Error; err != nil {
return nil
@@ -114,12 +123,15 @@ func (ts *TaskService) UpdateTask(id string, name, command, schedule string, tim
if triggerType != "" {
task.TriggerType = triggerType
}
if sourceID != "" {
task.SourceID = sourceID
}
database.DB.Model(&task).Select(
"Name", "Command", "Tags", "Schedule", "Timeout", "WorkDir",
"CleanConfig", "Envs", "Enabled", "AgentID", "Languages",
"RetryCount", "RetryInterval", "RandomRange", "Type",
"TriggerType", "Config",
"TriggerType", "Config", "SourceID",
).Updates(&task)
return &task
}
@@ -131,3 +143,11 @@ func (ts *TaskService) DeleteTask(id string) bool {
result := database.DB.Unscoped().Where("id = ?", id).Delete(&models.Task{})
return result.RowsAffected > 0
}
func (ts *TaskService) BatchDeleteTasks(ids []string) int64 {
// 同时删除关联的通知推送设置
database.DB.Where("type = ? AND data_id IN ?", constant.BindingTypeTask, ids).Delete(&models.NotifyBinding{})
result := database.DB.Unscoped().Where("id IN ?", ids).Delete(&models.Task{})
return result.RowsAffected
}
+34
View File
@@ -0,0 +1,34 @@
# 更新日志 ☕
本页面记录了白虎面板的主要版本更新历史。
## 最近更新概览
### 2026.03.19 - 仓库同步增强 (ql repo 兼容)
- **青龙指令兼容**:深度支持青龙仓库格式指令,支持自动解析脚本注释并同步为面板任务。
- **高级过滤逻辑**:支持基于正则的白名单、黑名单及依赖文件匹配特性。
- **解析器优化**:优化了定时任务 Cron 规则解析逻辑,修正了带 `#` 注释行的识别问题。
### 2026.03.05 - API 文档重构
- **OpenAPI 认证体系**:支持站点级 Token 配置与 Basic Auth 保护。
- **自定义 UI**:新增设计感十足的全局 **404 页面**
### 2026.03.04 - 消息推送系统重构
- **原生内置**:全新原生支持企业微信、钉钉、飞书、Telegram、Bark、邮件等十余种主流渠道。
- **事件捕获**:接入系统级事件通知自动捕获,告别原有必配外部推送服务的繁琐历史。
### 2026.02.13 - 任务执行引擎重构
- **深度集成 Mise**:支持 Python, Node.js, Go, Rust, PHP 等几乎所有主流语言的动态安装与多版本切换。
- **依赖管理**:同步上线跨语言统一依赖管理系统。
### 2026.02.11 - 安全性增强
- **随机密码策略**:首次启动使用随机密码并打印在日志中。
- **暴力破解防护**:登录接口增加防暴力破解。
- **路径遍历防护**:文件系统操作增加路径穿越锁定。
### 2026.02.10 - 任务调度重构
- **调度性能**:重写了并发控制逻辑,完善了任务队列。
- **体验优化**:优化文件树交互体验,支持任务执行实时日志流。
### 2026.02.06 - 镜像扩展
- **Debian 13 支持**:增加对 Debian 13 (Trixie) 镜像支持,整理 Docker 目录结构。
+47
View File
@@ -0,0 +1,47 @@
package utils
import "strings"
// GetRepoIdentifier 返回根据仓库URL和分支生成的作者_仓库名标识符
func GetRepoIdentifier(url string, branch string) string {
url = strings.TrimSuffix(url, ".git")
url = strings.TrimSuffix(url, "/")
repoName := url[strings.LastIndex(url, "/")+1:]
author := ""
lastSlash := strings.LastIndex(url, "/")
if lastSlash != -1 {
prefix := url[:lastSlash]
if strings.Contains(prefix, ":") {
parts := strings.Split(prefix, ":")
prefix = parts[len(parts)-1]
}
lastSlashPrefix := strings.LastIndex(prefix, "/")
if lastSlashPrefix != -1 {
author = prefix[lastSlashPrefix+1:]
} else {
author = prefix
}
}
if dotIdx := strings.LastIndex(author, "."); dotIdx != -1 {
author = author[dotIdx+1:]
}
identifier := ""
if author != "" {
identifier = author + "_" + repoName
} else {
identifier = repoName
}
if branch != "" && branch != "master" && branch != "main" {
identifier = identifier + "_" + branch
}
// Replace any invalid characters for tags or paths
identifier = strings.ReplaceAll(identifier, "/", "_")
identifier = strings.ReplaceAll(identifier, ".", "_")
return identifier
}