feat: add startup task and fix backup import

This commit is contained in:
engigu
2026-02-26 10:43:49 +08:00
parent afb412e71c
commit acc65dce64
12 changed files with 131 additions and 66 deletions
+4
View File
@@ -78,6 +78,10 @@ const (
TaskTypeNormal = "task"
TaskTypeRepo = "repo"
// 触发类型
TriggerTypeCron = "cron"
TriggerTypeBaihuStartup = "baihu_startup"
// Agent 状态
AgentStatusOnline = "online"
AgentStatusOffline = "offline"
+10 -6
View File
@@ -56,13 +56,14 @@ func (tc *TaskController) CreateTask(c *gin.Context) {
Command string `json:"command"`
Type string `json:"type"`
Config string `json:"config"`
Schedule string `json:"schedule" binding:"required"`
Schedule string `json:"schedule"`
Timeout int `json:"timeout"`
WorkDir string `json:"work_dir"`
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"`
}
if err := c.ShouldBindJSON(&req); err != nil {
@@ -76,9 +77,11 @@ func (tc *TaskController) CreateTask(c *gin.Context) {
return
}
if err := tc.executorService.ValidateCron(req.Schedule); err != nil {
utils.BadRequest(c, "无效的cron表达式: "+err.Error())
return
if req.Schedule != "" {
if err := tc.executorService.ValidateCron(req.Schedule); err != nil {
utils.BadRequest(c, "无效的cron表达式: "+err.Error())
return
}
}
// 转换为绝对路径(Agent 任务保持原样)
@@ -87,7 +90,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)
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)
// 如果是 Agent 任务,通知 Agent;否则添加到本地 cron
if task.AgentID != nil && *task.AgentID > 0 {
@@ -159,6 +162,7 @@ func (tc *TaskController) UpdateTask(c *gin.Context) {
Enabled bool `json:"enabled"`
Languages []map[string]string `json:"languages"`
AgentID *uint `json:"agent_id"`
TriggerType string `json:"trigger_type"`
}
if err := c.ShouldBindJSON(&req); err != nil {
@@ -179,7 +183,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)
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)
if task == nil {
utils.NotFound(c, "任务不存在")
return
+1
View File
@@ -38,6 +38,7 @@ type Task struct {
Name string `json:"name" gorm:"size:255;not null"`
Command string `json:"command" gorm:"type:text"` // 普通任务的命令
Type string `json:"type" gorm:"size:20;default:'task'"` // 任务类型: constant.TaskTypeNormal, constant.TaskTypeRepo
TriggerType string `json:"trigger_type" gorm:"size:25;default:'cron'"` // 触发类型: constant.TriggerTypeCron, constant.TriggerTypeBaihuStartup
Config string `json:"config" gorm:"type:text"` // 配置 JSON(仓库同步配置等)
Schedule string `json:"schedule" gorm:"size:100"` // cron 表达式
Timeout int `json:"timeout" gorm:"default:30"` // 超时时间(分钟),默认30分钟
+2
View File
@@ -11,6 +11,7 @@ type TaskVO struct {
Name string `json:"name"`
Command string `json:"command"`
Type string `json:"type"`
TriggerType string `json:"trigger_type"`
Config string `json:"config"`
Schedule string `json:"schedule"`
Timeout int `json:"timeout"`
@@ -36,6 +37,7 @@ func ToTaskVO(task *models.Task) *TaskVO {
Name: task.Name,
Command: task.Command,
Type: task.Type,
TriggerType: task.TriggerType,
Config: task.Config,
Schedule: task.Schedule,
Timeout: task.Timeout,
+49 -53
View File
@@ -227,6 +227,32 @@ func (s *BackupService) Restore(zipPath string) error {
})
}
func restoreStreamBatch[T any](tx *gorm.DB, decoder *json.Decoder) error {
batchSize := 1000
var batch []*T
for decoder.More() {
var m T
if err := decoder.Decode(&m); err != nil {
return err
}
batch = append(batch, &m)
if len(batch) >= batchSize {
if err := tx.CreateInBatches(batch, batchSize).Error; err != nil {
return err
}
batch = nil // reset batch
}
}
if len(batch) > 0 {
return tx.CreateInBatches(batch, len(batch)).Error
}
return nil
}
func (s *BackupService) restoreFromZipFile(tx *gorm.DB, f *zip.File, filename string) error {
rc, err := f.Open()
if err != nil {
@@ -254,60 +280,30 @@ func (s *BackupService) restoreFromZipFile(tx *gorm.DB, f *zip.File, filename st
return fmt.Errorf("invalid json format: expected %s", filename)
}
batchSize := 1000
var batch []any
// 根据文件名确定模型类型
getModel := func() any {
switch filename {
case "tasks.json":
return &models.Task{}
case "task_logs.json":
return &models.TaskLog{}
case "envs.json":
return &models.EnvironmentVariable{}
case "scripts.json":
return &models.Script{}
case "send_stats.json":
return &models.SendStats{}
case "login_logs.json":
return &models.LoginLog{}
case "agents.json":
return &models.Agent{}
case "tokens.json":
return &models.AgentToken{}
case "languages.json":
return &models.Language{}
case "deps.json":
return &models.Dependency{}
default:
return nil
}
switch filename {
case "tasks.json":
return restoreStreamBatch[models.Task](tx, decoder)
case "task_logs.json":
return restoreStreamBatch[models.TaskLog](tx, decoder)
case "envs.json":
return restoreStreamBatch[models.EnvironmentVariable](tx, decoder)
case "scripts.json":
return restoreStreamBatch[models.Script](tx, decoder)
case "send_stats.json":
return restoreStreamBatch[models.SendStats](tx, decoder)
case "login_logs.json":
return restoreStreamBatch[models.LoginLog](tx, decoder)
case "agents.json":
return restoreStreamBatch[models.Agent](tx, decoder)
case "tokens.json":
return restoreStreamBatch[models.AgentToken](tx, decoder)
case "languages.json":
return restoreStreamBatch[models.Language](tx, decoder)
case "deps.json":
return restoreStreamBatch[models.Dependency](tx, decoder)
default:
return nil
}
for decoder.More() {
m := getModel()
if m == nil {
break
}
if err := decoder.Decode(m); err != nil {
return err
}
batch = append(batch, m)
if len(batch) >= batchSize {
if err := tx.CreateInBatches(batch, batchSize).Error; err != nil {
return err
}
batch = nil
}
}
if len(batch) > 0 {
return tx.CreateInBatches(batch, batchSize).Error
}
return nil
}
// insertRecords, restoreFromData 方法已合并入 restoreFromZipFile,此处删除冗余方法
+17 -2
View File
@@ -416,6 +416,10 @@ func (es *ExecutorService) StopCron() {
// AddCronTask 添加计划任务
func (es *ExecutorService) AddCronTask(task *models.Task) error {
if task.TriggerType != constant.TriggerTypeCron {
es.RemoveCronTask(task.ID) // 如果不是cron类型,确保从调度器移除
return nil
}
return es.cronManager.AddTask(task)
}
@@ -439,8 +443,19 @@ func (es *ExecutorService) loadCronTasks() {
tasks := es.taskService.GetTasks()
count := 0
for _, task := range tasks {
// 只调度本地任务(agent_id 为空或 0)
if task.Enabled && (task.AgentID == nil || *task.AgentID == 0) {
if !task.Enabled {
continue
}
if task.TriggerType == constant.TriggerTypeBaihuStartup {
go func(t models.Task) {
// 延迟一点时间再触发,确保系统完全启动
time.Sleep(3 * time.Second)
logger.Infof("[Executor] 触发开机服务启动任务 #%d: %s", t.ID, t.Name)
es.ExecuteTask(int(t.ID), nil)
}(task)
} else if task.TriggerType == constant.TriggerTypeCron && task.Schedule != "" && (task.AgentID == nil || *task.AgentID == 0) {
// 只调度本地任务(agent_id 为空或 0)的定时任务
err := es.cronManager.AddTask(&task)
if err != nil {
continue
+16 -2
View File
@@ -1,6 +1,7 @@
package tasks
import (
"github.com/engigu/baihu-panel/internal/constant"
"github.com/engigu/baihu-panel/internal/database"
"github.com/engigu/baihu-panel/internal/models"
)
@@ -11,14 +12,18 @@ 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) *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) *models.Task {
if taskType == "" {
taskType = "task"
}
if triggerType == "" {
triggerType = constant.TriggerTypeCron
}
task := &models.Task{
Name: name,
Command: command,
Type: taskType,
TriggerType: triggerType,
Config: config,
Schedule: schedule,
Timeout: timeout,
@@ -29,6 +34,9 @@ func (ts *TaskService) CreateTask(name, command, schedule string, timeout int, w
AgentID: agentID,
Enabled: true,
}
if triggerType != constant.TriggerTypeCron {
task.NextRun = nil
}
database.DB.Create(task)
return task
}
@@ -66,7 +74,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) *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) *models.Task {
var task models.Task
if err := database.DB.First(&task, id).Error; err != nil {
return nil
@@ -84,6 +92,12 @@ func (ts *TaskService) UpdateTask(id int, name, command, schedule string, timeou
if taskType != "" {
task.Type = taskType
}
if triggerType != "" {
task.TriggerType = triggerType
}
if task.TriggerType != constant.TriggerTypeCron {
task.NextRun = nil
}
task.Config = config
database.DB.Save(&task)
return &task
View File
+1
View File
@@ -266,6 +266,7 @@ export interface Task {
name: string
command: string
type: string
trigger_type: string
config: string
schedule: string
timeout: number
+6
View File
@@ -34,6 +34,12 @@ export const TASK_TYPE = {
REPO: 'repo',
} as const
// 触发类型
export const TRIGGER_TYPE = {
CRON: 'cron',
BAIHU_STARTUP: 'baihu_startup',
} as const
// Agent 状态
export const AGENT_STATUS = {
ONLINE: 'online',
+20
View File
@@ -12,6 +12,7 @@ import DirTreeSelect from '@/components/DirTreeSelect.vue'
import { Plus, ChevronDown, X, Search, Check, ChevronsUpDown, Loader2, AlertCircle } from 'lucide-vue-next'
import { cn } from '@/lib/utils'
import { api, type Task, type EnvVar, type Agent, type MiseLanguage } from '@/api'
import { TRIGGER_TYPE } from '@/constants'
import { toast } from 'vue-sonner'
const props = defineProps<{
@@ -44,6 +45,7 @@ const allEnvVars = ref<EnvVar[]>([])
const allAgents = ref<Agent[]>([])
const selectedEnvIds = ref<number[]>([])
const selectedAgentId = ref<string>('local')
const selectedTriggerType = ref<string>('cron')
const envSearchQuery = ref('')
//
const workDirCache = ref<Record<string, string>>({})
@@ -254,6 +256,8 @@ watch(() => props.open, async (val) => {
// Agent
const agentId = props.task?.agent_id ? String(props.task.agent_id) : 'local'
selectedAgentId.value = agentId
//
selectedTriggerType.value = props.task?.trigger_type || TRIGGER_TYPE.CRON
//
workDirCache.value = {
[agentId]: props.task?.work_dir || ''
@@ -298,6 +302,7 @@ async function save() {
form.value.clean_config = cleanConfig.value
form.value.envs = selectedEnvIds.value.join(',')
form.value.type = 'task'
form.value.trigger_type = selectedTriggerType.value
form.value.agent_id = selectedAgentId.value === 'local' ? null : Number(selectedAgentId.value)
//
@@ -374,6 +379,21 @@ async function save() {
</div>
</div>
<div class="grid grid-cols-1 sm:grid-cols-4 items-center gap-2 sm:gap-3">
<Label class="sm:text-right text-sm">触发类型</Label>
<div class="sm:col-span-3">
<Select v-model="selectedTriggerType">
<SelectTrigger class="h-8 text-sm">
<SelectValue placeholder="定时触发" />
</SelectTrigger>
<SelectContent>
<SelectItem :value="TRIGGER_TYPE.CRON">定时触发</SelectItem>
<SelectItem :value="TRIGGER_TYPE.BAIHU_STARTUP">服务启动时触发</SelectItem>
</SelectContent>
</Select>
</div>
</div>
<!-- 本地任务语言版本配置 -->
<template v-if="selectedAgentId === 'local'">
<div class="grid grid-cols-1 sm:grid-cols-4 items-start gap-2 sm:gap-3">
+5 -3
View File
@@ -11,7 +11,7 @@ import { api, type Task, type Agent } from '@/api'
import { toast } from 'vue-sonner'
import { useSiteSettings } from '@/composables/useSiteSettings'
import { useRouter, useRoute } from 'vue-router'
import { TASK_TYPE, AGENT_STATUS } from '@/constants'
import { TASK_TYPE, AGENT_STATUS, TRIGGER_TYPE } from '@/constants'
import TextOverflow from '@/components/TextOverflow.vue'
const router = useRouter()
@@ -261,8 +261,10 @@ watch(() => route.query.agent_id, (newVal) => {
class="w-32 sm:flex-1 shrink-0 sm:shrink text-muted-foreground truncate text-xs bg-muted/40 px-2 py-1 rounded hidden sm:block">
<TextOverflow :text="task.command" :title="task.type === TASK_TYPE.REPO ? '同步地址' : '执行命令'" />
</code>
<code class="w-36 shrink-0 text-muted-foreground text-xs bg-muted/40 px-2 py-1 rounded hidden md:block">{{ task.schedule
}}</code>
<div class="w-36 shrink-0 hidden md:flex flex-col items-start justify-center gap-1 overflow-hidden">
<span v-if="task.trigger_type === TRIGGER_TYPE.BAIHU_STARTUP" class="text-[10px] leading-none bg-primary/10 text-primary px-1.5 py-1 rounded whitespace-nowrap border border-primary/20">服务启动时</span>
<code v-else-if="task.schedule" class="text-muted-foreground text-xs bg-muted/40 px-1.5 py-0.5 rounded truncate max-w-full" :title="task.schedule">{{ task.schedule }}</code>
</div>
<span class="w-40 shrink-0 text-muted-foreground text-xs hidden lg:block">{{ task.last_run || '-' }}</span>
<span class="w-40 shrink-0 text-muted-foreground text-xs hidden lg:block">{{ task.next_run || '-' }}</span>
<span class="w-8 sm:w-12 flex justify-center shrink-0 cursor-pointer group"