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" TaskTypeNormal = "task"
TaskTypeRepo = "repo" TaskTypeRepo = "repo"
// 触发类型
TriggerTypeCron = "cron"
TriggerTypeBaihuStartup = "baihu_startup"
// Agent 状态 // Agent 状态
AgentStatusOnline = "online" AgentStatusOnline = "online"
AgentStatusOffline = "offline" AgentStatusOffline = "offline"
+10 -6
View File
@@ -56,13 +56,14 @@ func (tc *TaskController) CreateTask(c *gin.Context) {
Command string `json:"command"` Command string `json:"command"`
Type string `json:"type"` Type string `json:"type"`
Config string `json:"config"` Config string `json:"config"`
Schedule string `json:"schedule" binding:"required"` Schedule string `json:"schedule"`
Timeout int `json:"timeout"` Timeout int `json:"timeout"`
WorkDir string `json:"work_dir"` WorkDir string `json:"work_dir"`
CleanConfig string `json:"clean_config"` CleanConfig string `json:"clean_config"`
Envs string `json:"envs"` Envs string `json:"envs"`
Languages []map[string]string `json:"languages"` Languages []map[string]string `json:"languages"`
AgentID *uint `json:"agent_id"` AgentID *uint `json:"agent_id"`
TriggerType string `json:"trigger_type"`
} }
if err := c.ShouldBindJSON(&req); err != nil { if err := c.ShouldBindJSON(&req); err != nil {
@@ -76,9 +77,11 @@ func (tc *TaskController) CreateTask(c *gin.Context) {
return return
} }
if err := tc.executorService.ValidateCron(req.Schedule); err != nil { if req.Schedule != "" {
utils.BadRequest(c, "无效的cron表达式: "+err.Error()) if err := tc.executorService.ValidateCron(req.Schedule); err != nil {
return utils.BadRequest(c, "无效的cron表达式: "+err.Error())
return
}
} }
// 转换为绝对路径(Agent 任务保持原样) // 转换为绝对路径(Agent 任务保持原样)
@@ -87,7 +90,7 @@ func (tc *TaskController) CreateTask(c *gin.Context) {
workDir = resolveWorkDir(req.WorkDir) 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 // 如果是 Agent 任务,通知 Agent;否则添加到本地 cron
if task.AgentID != nil && *task.AgentID > 0 { if task.AgentID != nil && *task.AgentID > 0 {
@@ -159,6 +162,7 @@ func (tc *TaskController) UpdateTask(c *gin.Context) {
Enabled bool `json:"enabled"` Enabled bool `json:"enabled"`
Languages []map[string]string `json:"languages"` Languages []map[string]string `json:"languages"`
AgentID *uint `json:"agent_id"` AgentID *uint `json:"agent_id"`
TriggerType string `json:"trigger_type"`
} }
if err := c.ShouldBindJSON(&req); err != nil { if err := c.ShouldBindJSON(&req); err != nil {
@@ -179,7 +183,7 @@ func (tc *TaskController) UpdateTask(c *gin.Context) {
workDir = resolveWorkDir(req.WorkDir) 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 { if task == nil {
utils.NotFound(c, "任务不存在") utils.NotFound(c, "任务不存在")
return return
+1
View File
@@ -38,6 +38,7 @@ type Task struct {
Name string `json:"name" gorm:"size:255;not null"` Name string `json:"name" gorm:"size:255;not null"`
Command string `json:"command" gorm:"type:text"` // 普通任务的命令 Command string `json:"command" gorm:"type:text"` // 普通任务的命令
Type string `json:"type" gorm:"size:20;default:'task'"` // 任务类型: constant.TaskTypeNormal, constant.TaskTypeRepo 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(仓库同步配置等) Config string `json:"config" gorm:"type:text"` // 配置 JSON(仓库同步配置等)
Schedule string `json:"schedule" gorm:"size:100"` // cron 表达式 Schedule string `json:"schedule" gorm:"size:100"` // cron 表达式
Timeout int `json:"timeout" gorm:"default:30"` // 超时时间(分钟),默认30分钟 Timeout int `json:"timeout" gorm:"default:30"` // 超时时间(分钟),默认30分钟
+2
View File
@@ -11,6 +11,7 @@ type TaskVO struct {
Name string `json:"name"` Name string `json:"name"`
Command string `json:"command"` Command string `json:"command"`
Type string `json:"type"` Type string `json:"type"`
TriggerType string `json:"trigger_type"`
Config string `json:"config"` Config string `json:"config"`
Schedule string `json:"schedule"` Schedule string `json:"schedule"`
Timeout int `json:"timeout"` Timeout int `json:"timeout"`
@@ -36,6 +37,7 @@ func ToTaskVO(task *models.Task) *TaskVO {
Name: task.Name, Name: task.Name,
Command: task.Command, Command: task.Command,
Type: task.Type, Type: task.Type,
TriggerType: task.TriggerType,
Config: task.Config, Config: task.Config,
Schedule: task.Schedule, Schedule: task.Schedule,
Timeout: task.Timeout, 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 { func (s *BackupService) restoreFromZipFile(tx *gorm.DB, f *zip.File, filename string) error {
rc, err := f.Open() rc, err := f.Open()
if err != nil { 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) return fmt.Errorf("invalid json format: expected %s", filename)
} }
batchSize := 1000 switch filename {
var batch []any case "tasks.json":
return restoreStreamBatch[models.Task](tx, decoder)
// 根据文件名确定模型类型 case "task_logs.json":
getModel := func() any { return restoreStreamBatch[models.TaskLog](tx, decoder)
switch filename { case "envs.json":
case "tasks.json": return restoreStreamBatch[models.EnvironmentVariable](tx, decoder)
return &models.Task{} case "scripts.json":
case "task_logs.json": return restoreStreamBatch[models.Script](tx, decoder)
return &models.TaskLog{} case "send_stats.json":
case "envs.json": return restoreStreamBatch[models.SendStats](tx, decoder)
return &models.EnvironmentVariable{} case "login_logs.json":
case "scripts.json": return restoreStreamBatch[models.LoginLog](tx, decoder)
return &models.Script{} case "agents.json":
case "send_stats.json": return restoreStreamBatch[models.Agent](tx, decoder)
return &models.SendStats{} case "tokens.json":
case "login_logs.json": return restoreStreamBatch[models.AgentToken](tx, decoder)
return &models.LoginLog{} case "languages.json":
case "agents.json": return restoreStreamBatch[models.Language](tx, decoder)
return &models.Agent{} case "deps.json":
case "tokens.json": return restoreStreamBatch[models.Dependency](tx, decoder)
return &models.AgentToken{} default:
case "languages.json": return nil
return &models.Language{}
case "deps.json":
return &models.Dependency{}
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,此处删除冗余方法 // insertRecords, restoreFromData 方法已合并入 restoreFromZipFile,此处删除冗余方法
+17 -2
View File
@@ -416,6 +416,10 @@ func (es *ExecutorService) StopCron() {
// AddCronTask 添加计划任务 // AddCronTask 添加计划任务
func (es *ExecutorService) AddCronTask(task *models.Task) error { 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) return es.cronManager.AddTask(task)
} }
@@ -439,8 +443,19 @@ func (es *ExecutorService) loadCronTasks() {
tasks := es.taskService.GetTasks() tasks := es.taskService.GetTasks()
count := 0 count := 0
for _, task := range tasks { for _, task := range tasks {
// 只调度本地任务(agent_id 为空或 0) if !task.Enabled {
if task.Enabled && (task.AgentID == nil || *task.AgentID == 0) { 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) err := es.cronManager.AddTask(&task)
if err != nil { if err != nil {
continue continue
+16 -2
View File
@@ -1,6 +1,7 @@
package tasks package tasks
import ( import (
"github.com/engigu/baihu-panel/internal/constant"
"github.com/engigu/baihu-panel/internal/database" "github.com/engigu/baihu-panel/internal/database"
"github.com/engigu/baihu-panel/internal/models" "github.com/engigu/baihu-panel/internal/models"
) )
@@ -11,14 +12,18 @@ func NewTaskService() *TaskService {
return &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 == "" { if taskType == "" {
taskType = "task" taskType = "task"
} }
if triggerType == "" {
triggerType = constant.TriggerTypeCron
}
task := &models.Task{ task := &models.Task{
Name: name, Name: name,
Command: command, Command: command,
Type: taskType, Type: taskType,
TriggerType: triggerType,
Config: config, Config: config,
Schedule: schedule, Schedule: schedule,
Timeout: timeout, Timeout: timeout,
@@ -29,6 +34,9 @@ func (ts *TaskService) CreateTask(name, command, schedule string, timeout int, w
AgentID: agentID, AgentID: agentID,
Enabled: true, Enabled: true,
} }
if triggerType != constant.TriggerTypeCron {
task.NextRun = nil
}
database.DB.Create(task) database.DB.Create(task)
return task return task
} }
@@ -66,7 +74,7 @@ func (ts *TaskService) GetTaskByID(id int) *models.Task {
return &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 var task models.Task
if err := database.DB.First(&task, id).Error; err != nil { if err := database.DB.First(&task, id).Error; err != nil {
return nil return nil
@@ -84,6 +92,12 @@ func (ts *TaskService) UpdateTask(id int, name, command, schedule string, timeou
if taskType != "" { if taskType != "" {
task.Type = taskType task.Type = taskType
} }
if triggerType != "" {
task.TriggerType = triggerType
}
if task.TriggerType != constant.TriggerTypeCron {
task.NextRun = nil
}
task.Config = config task.Config = config
database.DB.Save(&task) database.DB.Save(&task)
return &task return &task
View File
+1
View File
@@ -266,6 +266,7 @@ export interface Task {
name: string name: string
command: string command: string
type: string type: string
trigger_type: string
config: string config: string
schedule: string schedule: string
timeout: number timeout: number
+6
View File
@@ -34,6 +34,12 @@ export const TASK_TYPE = {
REPO: 'repo', REPO: 'repo',
} as const } as const
// 触发类型
export const TRIGGER_TYPE = {
CRON: 'cron',
BAIHU_STARTUP: 'baihu_startup',
} as const
// Agent 状态 // Agent 状态
export const AGENT_STATUS = { export const AGENT_STATUS = {
ONLINE: 'online', 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 { Plus, ChevronDown, X, Search, Check, ChevronsUpDown, Loader2, AlertCircle } from 'lucide-vue-next'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
import { api, type Task, type EnvVar, type Agent, type MiseLanguage } from '@/api' import { api, type Task, type EnvVar, type Agent, type MiseLanguage } from '@/api'
import { TRIGGER_TYPE } from '@/constants'
import { toast } from 'vue-sonner' import { toast } from 'vue-sonner'
const props = defineProps<{ const props = defineProps<{
@@ -44,6 +45,7 @@ const allEnvVars = ref<EnvVar[]>([])
const allAgents = ref<Agent[]>([]) const allAgents = ref<Agent[]>([])
const selectedEnvIds = ref<number[]>([]) const selectedEnvIds = ref<number[]>([])
const selectedAgentId = ref<string>('local') const selectedAgentId = ref<string>('local')
const selectedTriggerType = ref<string>('cron')
const envSearchQuery = ref('') const envSearchQuery = ref('')
// 为每个执行位置保存独立的工作目录配置 // 为每个执行位置保存独立的工作目录配置
const workDirCache = ref<Record<string, string>>({}) const workDirCache = ref<Record<string, string>>({})
@@ -254,6 +256,8 @@ watch(() => props.open, async (val) => {
// 解析 Agent 和工作目录 // 解析 Agent 和工作目录
const agentId = props.task?.agent_id ? String(props.task.agent_id) : 'local' const agentId = props.task?.agent_id ? String(props.task.agent_id) : 'local'
selectedAgentId.value = agentId selectedAgentId.value = agentId
// 解析触发类型
selectedTriggerType.value = props.task?.trigger_type || TRIGGER_TYPE.CRON
// 初始化工作目录缓存,将当前任务的工作目录保存到对应的执行位置 // 初始化工作目录缓存,将当前任务的工作目录保存到对应的执行位置
workDirCache.value = { workDirCache.value = {
[agentId]: props.task?.work_dir || '' [agentId]: props.task?.work_dir || ''
@@ -298,6 +302,7 @@ async function save() {
form.value.clean_config = cleanConfig.value form.value.clean_config = cleanConfig.value
form.value.envs = selectedEnvIds.value.join(',') form.value.envs = selectedEnvIds.value.join(',')
form.value.type = 'task' form.value.type = 'task'
form.value.trigger_type = selectedTriggerType.value
form.value.agent_id = selectedAgentId.value === 'local' ? null : Number(selectedAgentId.value) form.value.agent_id = selectedAgentId.value === 'local' ? null : Number(selectedAgentId.value)
// 保存语言环境配置 // 保存语言环境配置
@@ -374,6 +379,21 @@ async function save() {
</div> </div>
</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'"> <template v-if="selectedAgentId === 'local'">
<div class="grid grid-cols-1 sm:grid-cols-4 items-start gap-2 sm:gap-3"> <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 { toast } from 'vue-sonner'
import { useSiteSettings } from '@/composables/useSiteSettings' import { useSiteSettings } from '@/composables/useSiteSettings'
import { useRouter, useRoute } from 'vue-router' 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' import TextOverflow from '@/components/TextOverflow.vue'
const router = useRouter() 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"> 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 ? '同步地址' : '执行命令'" /> <TextOverflow :text="task.command" :title="task.type === TASK_TYPE.REPO ? '同步地址' : '执行命令'" />
</code> </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 <div class="w-36 shrink-0 hidden md:flex flex-col items-start justify-center gap-1 overflow-hidden">
}}</code> <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.last_run || '-' }}</span>
<span class="w-40 shrink-0 text-muted-foreground text-xs hidden lg:block">{{ task.next_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" <span class="w-8 sm:w-12 flex justify-center shrink-0 cursor-pointer group"