一、引言
定时任务调度是后端系统中最常见也最容易出错的场景之一。本文以 Go 语言实现一个完整的定时任务调度器 TaskScheduler,从领域模型、仓储层、调度引擎到命令行与 GUI 界面,逐步拆解一个可落地的工程化实现。全文覆盖从基础概念到进阶应用,帮助读者理解 DDD 分层、cron 调度、JSON 导入导出以及桌面端集成等核心知识点。
二、基础概念与项目结构
在动手写代码之前,先厘清几个关键概念:任务实体、执行历史、任务状态,以及整个项目的分层结构。
- 任务实体(TaskEntity):任务名称、cron 表达式、执行命令、超时秒数、重试次数等字段,与数据库 t_task 表一一对应。
- 执行历史(TaskHistoryEntity):记录每次任务的开始时间、结束时间、成功与否以及输出日志。
- 任务状态(TaskStatus):IDLE 空闲待调度、RUNNING 正在运行两种状态。
- 项目分层:domain(领域实体)、repository(仓储层)、application(应用服务)、scheduler(调度引擎)、jsonio(导入导出)、storage(数据库)、logger(日志)、gui(桌面界面)。
三、核心实现:领域模型与仓储层
本节聚焦任务调度器的数据底座,讲解领域实体如何设计、仓储层如何完成数据库与实体之间的双向转换。
- 领域实体设计:TaskEntity 与 TaskHistoryEntity 作为纯数据对象,不依赖任何数据库实现,保持 DDD 分层清晰。
- 任务状态枚举:TaskStatus 提供 ParseTaskStatus 与 String 方法,实现字符串与枚举的安全互转。
- 仓储层 CRUD:TaskRepo 封装 GetByID、GetList、GetEnabledList、Save、DeleteByID 等完整操作,scanTask 与 scanTasks 负责行数据到实体的转换。
四、进阶应用:调度引擎与 JSON 导入导出
在数据层之上,本节讲解调度线程如何驱动任务执行,以及如何通过 JSON 实现任务的批量迁移与备份。
- 调度引擎:后台调度线程按 cron 表达式触发任务,支持超时控制、失败重试、一次性任务执行后自动禁用。
- JSON 导入导出:TaskJsonIO 提供 ExportAll 与 ImportFromFile,支持全量导出与批量新增/更新,并返回导入统计。
- 日志与配置:logger 基于 lumberjack 实现控制台与滚动文件双输出,config 统一管理应用配置。
五、实战示例:命令行与 GUI 集成
最后通过两个可运行的入口串联全部模块,展示调度器在真实场景中的完整用法。
- 命令行入口:main.go 提供 list、add、del、enable、run、runonce、history、import、export 等命令,覆盖任务全生命周期管理。
- GUI 桌面端:基于 walk 构建主窗口,包含任务列表、仪表盘、运行日志面板,并支持托盘与优雅退出。
- 运行效果:通过截图展示命令行输出与桌面界面,验证调度、执行、历史查询等功能的实际表现。
六、总结
本文从零实现了一个功能完整的 Go 定时任务调度器,覆盖领域建模、仓储持久化、调度引擎、JSON 迁移以及命令行与 GUI 双入口。读者可以在此基础上继续扩展分布式调度、任务依赖、Web 管理后台等进阶能力。
项目结构:

Go
/*
# 版权所有 2026 ©涂聚文有限公司™ ®
# 许可信息查看:言語成了邀功盡責的功臣,還需要行爲每日來值班嗎
# 描述: Algorithms
# Author : geovindu,Geovin Du 涂聚文.
# IDE : goLang 2024.3.6 go 26.2
# os : windows 10
# database : mysql 9.0 sql server 2019, postgreSQL 17.0 Oracle 21c Neo4j
# Datetime : 2026/7/8 23:07
# User : geovindu
# Product : GoLand
# Project : goalgorithms
# File : task_entity.go
*/
/*
domain/task_entity.go
任务领域实体(DDD纯数据对象,无数据库依赖)
对应 Python 版 domain/task_entity.py TaskEntity
字段与数据库 t_task 表严格一一对应
*/
package domain
// TaskEntity 任务领域实体
type TaskEntity struct {
TaskID int64 // 任务主键ID(新增时为0,数据库自增)
TaskName string // 任务名称
CronExpr string // cron表达式
Command string // 执行命令
TimeoutSec int // 子进程超时秒数
MaxRetry int // 最大重试次数(0=不重试)
RetryGapSec int // 重试间隔秒数
IsEnable bool // 是否启用
Remark string // 备注(含"一次性"则执行成功后自动禁用)
TaskStatus TaskStatus // 运行状态 IDLE/RUNNING
}
/*
版权所有 2026 ©涂聚文有限公司™ ®
许可信息查看:言語成了邀功盡責的功臣,還需要行爲每日來值班嗎
描述: Algorithms
Author : geovindu,Geovin Du 涂聚文.
IDE : goLang 2024.3.6 go 26.2
os : windows 10
database : mysql 9.0 sql server 2019, postgreSQL 17.0 Oracle 21c Neo4j
Datetime : 2026/7/8 23:07
User : geovindu
Product : GoLand
Project : goalgorithms
File : task_history_entity.go
*/
/*
domain/task_history_entity.go
任务执行历史领域实体
对应 Python 版 domain/task_history_entity.py TaskHistoryEntity
字段与数据库 t_task_history 表严格一一对应
*/
package domain
import "time"
// TaskHistoryEntity 任务执行历史实体
type TaskHistoryEntity struct {
HistoryID int64 // 历史主键ID(新增时为0,数据库自增)
TaskID int64 // 关联任务ID
ExecuteStartTime time.Time // 执行开始时间
ExecuteEndTime time.Time // 执行结束时间
IsSuccess bool // 是否执行成功
ExecuteMsg string // 命令输出日志
}
/*
版权所有 2026 ©涂聚文有限公司™ ®
许可信息查看:言語成了邀功盡責的功臣,還需要行爲每日來值班嗎
描述: Algorithms
Author : geovindu,Geovin Du 涂聚文.
IDE : goLang 2024.3.6 go 26.2
os : windows 10
database : mysql 9.0 sql server 2019, postgreSQL 17.0 Oracle 21c Neo4j
Datetime : 2026/7/8 23:07
User : geovindu
Product : GoLand
Project : goalgorithms
File : task_status.go
/
/
domain/task_status.go
任务状态枚举
对应 Python 版 domain/scheduler_enum.py TaskStatus
*/
package domain
// TaskStatus 任务运行状态类型
type TaskStatus string
const (
// TaskStatusIDLE 空闲待调度
TaskStatusIDLE TaskStatus = "IDLE"
// TaskStatusRUNNING 正在运行
TaskStatusRUNNING TaskStatus = "RUNNING"
)
// ParseTaskStatus 字符串转任务状态枚举,非法值回退为 IDLE
func ParseTaskStatus(s string) TaskStatus {
switch s {
case string(TaskStatusRUNNING):
return TaskStatusRUNNING
default:
return TaskStatusIDLE
}
}
// String 返回状态字符串表示
func (t TaskStatus) String() string {
return string(t)
}
/*
版权所有 2026 ©涂聚文有限公司™ ®
许可信息查看:言語成了邀功盡責的功臣,還需要行爲每日來值班嗎
描述: Algorithms
Author : geovindu,Geovin Du 涂聚文.
IDE : goLang 2024.3.6 go 26.2
os : windows 10
database : mysql 9.0 sql server 2019, postgreSQL 17.0 Oracle 21c Neo4j
Datetime : 2026/7/8 23:07
User : geovindu
Product : GoLand
Project : goalgorithms
File : task_json_io.go
*/
/*
jsonio/task_json_io.go
任务JSON导入导出功能
对应 Python 版 infrastructure/json_import_export.py
导出:全部任务导出为JSON文件
导入:读取JSON批量新增/更新任务
*/
package jsonio
import (
"encoding/json"
"fmt"
"log"
"os"
"taskscheduler/domain"
"taskscheduler/repository"
"taskscheduler/storage"
)
// TaskJsonItem JSON中的任务条目结构
type TaskJsonItem struct {
TaskID int64 json:"task_id" // 任务ID(0=新增)
TaskName string json:"task_name" // 任务名称
CronExpr string json:"cron_expr" // cron表达式
Command string json:"command" // 执行命令
TimeoutSec int json:"timeout_sec" // 超时秒数
MaxRetry int json:"max_retry" // 最大重试次数
RetryGapSec int json:"retry_gap_sec" // 重试间隔秒
IsEnable bool json:"is_enable" // 是否启用
Remark string json:"remark" // 备注
TaskStatus string json:"task_status" // 任务状态字符串
}
// ExportStat 导入结果统计
type ExportStat struct {
Total int // 总条数
Insert int // 新增
Update int // 更新
Skip int // 跳过
}
// TaskJsonIO JSON导入导出工具
type TaskJsonIO struct {
Repo *repository.TaskRepo // 任务仓储
}
// NewTaskJsonIO 创建JSON导入导出工具
func NewTaskJsonIO() *TaskJsonIO {
return &TaskJsonIO{Repo: repository.NewTaskRepo()}
}
// ExportAll 导出全部任务到JSON文件
func (io *TaskJsonIO) ExportAll(filePath string) error {
list, err := io.Repo.GetList(storage.DB)
if err != nil {
return fmt.Errorf("查询任务失败: %w", err)
}
items := make([]TaskJsonItem, 0, len(list))
for _, e := range list {
items = append(items, TaskJsonItem{
TaskID: e.TaskID,
TaskName: e.TaskName,
CronExpr: e.CronExpr,
Command: e.Command,
TimeoutSec: e.TimeoutSec,
MaxRetry: e.MaxRetry,
RetryGapSec: e.RetryGapSec,
IsEnable: e.IsEnable,
Remark: e.Remark,
TaskStatus: e.TaskStatus.String(),
})
}
data, err := json.MarshalIndent(items, "", " ")
if err != nil {
return fmt.Errorf("JSON序列化失败: %w", err)
}
if err := os.WriteFile(filePath, data, 0644); err != nil {
return fmt.Errorf("写入文件失败: %w", err)
}
log.Printf("导出完成:%d 条任务 → %s", len(items), filePath)
return nil
}
// ImportFromFile 从JSON文件导入任务
// 返回值: 导入统计
func (io *TaskJsonIO) ImportFromFile(filePath string) (*ExportStat, error) {
data, err := os.ReadFile(filePath)
if err != nil {
return nil, fmt.Errorf("读取文件失败: %w", err)
}
var items []TaskJsonItem
if err := json.Unmarshal(data, &items); err != nil {
return nil, fmt.Errorf("JSON解析失败: %w", err)
}
stat := &ExportStat{Total: len(items)}
for _, item := range items {
entity := &domain.TaskEntity{
TaskID: item.TaskID,
TaskName: item.TaskName,
CronExpr: item.CronExpr,
Command: item.Command,
TimeoutSec: item.TimeoutSec,
MaxRetry: item.MaxRetry,
RetryGapSec: item.RetryGapSec,
IsEnable: item.IsEnable,
Remark: item.Remark,
TaskStatus: domain.ParseTaskStatus(item.TaskStatus),
}
// 校验必填字段
if entity.TaskName == "" || entity.CronExpr == "" || entity.Command == "" {
log.Printf("跳过无效条目(名称/cron/命令为空):%+v", item)
stat.Skip++
continue
}
// 判断新增或更新
if entity.TaskID <= 0 {
entity.TaskID = 0
if _, err := io.Repo.Save(storage.DB, entity); err != nil {
log.Printf("新增任务失败:%v", err)
stat.Skip++
continue
}
stat.Insert++
} else {
if _, err := io.Repo.Save(storage.DB, entity); err != nil {
log.Printf("更新任务失败:%v", err)
stat.Skip++
continue
}
stat.Update++
}
}
log.Printf("导入完成:总条数=%d 新增=%d 更新=%d 跳过=%d",
stat.Total, stat.Insert, stat.Update, stat.Skip)
return stat, nil
}
/*
版权所有 2026 ©涂聚文有限公司™ ®
许可信息查看:言語成了邀功盡責的功臣,還需要行爲每日來值班嗎
描述: Algorithms
Author : geovindu,Geovin Du 涂聚文.
IDE : goLang 2024.3.6 go 26.2
os : windows 10
database : mysql 9.0 sql server 2019, postgreSQL 17.0 Oracle 21c Neo4j
Datetime : 2026/7/8 23:07
User : geovindu
Product : GoLand
Project : goalgorithms
File : logger.go
*/
/*
logger/logger.go
日志初始化:控制台 + 滚动文件日志(lumberjack按大小切割)+ 可选GUI输出
对应 Python 版 main.py setup_logging + RotatingFileHandler
*/
package logger
import (
"io"
"log"
"os"
"path/filepath"
"gopkg.in/natefinch/lumberjack.v2"
)
// Init 初始化全局日志:控制台 + 文件滚动(单文件5MB,保留10个备份)
// 参数 logDir: 日志目录
func Init(logDir string) {
InitWithWriter(logDir, nil)
}
// InitWithWriter 初始化全局日志:控制台 + 文件滚动 + 自定义输出(如GUI文本框)
// 参数 logDir: 日志目录;extra: 额外写入目标(nil表示不添加)
func InitWithWriter(logDir string, extra io.Writer) {
// 确保日志目录存在
if err := os.MkdirAll(logDir, os.ModePerm); err != nil {
log.Printf("创建日志目录失败: %v", err)
}
logFile := filepath.Join(logDir, "app.log")
// lumberjack: 自动滚动切割日志文件
fileWriter := &lumberjack.Logger{
Filename: logFile, // 日志文件路径
MaxSize: 5, // 单个文件最大5MB
MaxBackups: 10, // 最多保留10个备份
MaxAge: 30, // 备份保留30天
Compress: false, // 不压缩
LocalTime: true, // 使用本地时间
}
// 组合输出:控制台 + 文件 + 可选额外目标(GUI)
writers := []io.Writer{os.Stdout, fileWriter}
if extra != nil {
writers = append(writers, extra)
}
multiWriter := io.MultiWriter(writers...)
log.SetOutput(multiWriter)
log.SetFlags(log.LstdFlags | log.Lmicroseconds)
log.Println("日志系统初始化完成")
}
/*
版权所有 2026 ©涂聚文有限公司™ ®
许可信息查看:言語成了邀功盡責的功臣,還需要行爲每日來值班嗎
描述: Algorithms
Author : geovindu,Geovin Du 涂聚文.
IDE : goLang 2024.3.6 go 26.2
os : windows 10
database : mysql 9.0 sql server 2019, postgreSQL 17.0 Oracle 21c Neo4j
Datetime : 2026/7/8 23:07
User : geovindu
Product : GoLand
Project : goalgorithms
File : logger.go
*/
/*
logger/logger.go
日志初始化:控制台 + 滚动文件日志(lumberjack按大小切割)+ 可选GUI输出
对应 Python 版 main.py setup_logging + RotatingFileHandler
*/
package logger
import (
"io"
"log"
"os"
"path/filepath"
"gopkg.in/natefinch/lumberjack.v2"
)
// Init 初始化全局日志:控制台 + 文件滚动(单文件5MB,保留10个备份)
// 参数 logDir: 日志目录
func Init(logDir string) {
InitWithWriter(logDir, nil)
}
// InitWithWriter 初始化全局日志:控制台 + 文件滚动 + 自定义输出(如GUI文本框)
// 参数 logDir: 日志目录;extra: 额外写入目标(nil表示不添加)
func InitWithWriter(logDir string, extra io.Writer) {
// 确保日志目录存在
if err := os.MkdirAll(logDir, os.ModePerm); err != nil {
log.Printf("创建日志目录失败: %v", err)
}
logFile := filepath.Join(logDir, "app.log")
// lumberjack: 自动滚动切割日志文件
fileWriter := &lumberjack.Logger{
Filename: logFile, // 日志文件路径
MaxSize: 5, // 单个文件最大5MB
MaxBackups: 10, // 最多保留10个备份
MaxAge: 30, // 备份保留30天
Compress: false, // 不压缩
LocalTime: true, // 使用本地时间
}
// 组合输出:控制台 + 文件 + 可选额外目标(GUI)
writers := []io.Writer{os.Stdout, fileWriter}
if extra != nil {
writers = append(writers, extra)
}
multiWriter := io.MultiWriter(writers...)
log.SetOutput(multiWriter)
log.SetFlags(log.LstdFlags | log.Lmicroseconds)
log.Println("日志系统初始化完成")
}
/*
版权所有 2026 ©涂聚文有限公司™ ®
许可信息查看:言語成了邀功盡責的功臣,還需要行爲每日來值班嗎
描述: Algorithms
Author : geovindu,Geovin Du 涂聚文.
IDE : goLang 2024.3.6 go 26.2
os : windows 10
database : mysql 9.0 sql server 2019, postgreSQL 17.0 Oracle 21c Neo4j
Datetime : 2026/7/8 23:07
User : geovindu
Product : GoLand
Project : goalgorithms
File : task_repo.go
*/
/*
repository/task_repo.go
任务仓储层:完整CRUD,数据库行与领域实体双向转换
对应 Python 版 infrastructure/repository/task_repo.py
*/
package repository
import (
"database/sql"
"errors"
"fmt"
"taskscheduler/domain"
)
// TaskRepo 任务仓储
type TaskRepo struct{}
// NewTaskRepo 创建任务仓储实例
func NewTaskRepo() *TaskRepo {
return &TaskRepo{}
}
// scanTask 单行结果转领域实体
func scanTask(row *sql.Row) (*domain.TaskEntity, error) {
var (
id int64
taskName string
cronExpr string
command string
timeoutSec int
maxRetry int
retryGapSec int
isEnable int
remark sql.NullString
taskStatus string
)
if err := row.Scan(&id, &taskName, &cronExpr, &command,
&timeoutSec, &maxRetry, &retryGapSec, &isEnable, &remark, &taskStatus); err != nil {
return nil, err
}
remarkVal := ""
if remark.Valid {
remarkVal = remark.String
}
return &domain.TaskEntity{
TaskID: id,
TaskName: taskName,
CronExpr: cronExpr,
Command: command,
TimeoutSec: timeoutSec,
MaxRetry: maxRetry,
RetryGapSec: retryGapSec,
IsEnable: isEnable == 1,
Remark: remarkVal,
TaskStatus: domain.ParseTaskStatus(taskStatus),
}, nil
}
// scanTasks 多行结果转领域实体切片
func scanTasks(rows *sql.Rows) ([]*domain.TaskEntity, error) {
defer rows.Close()
var list []*domain.TaskEntity
for rows.Next() {
var (
id int64
taskName string
cronExpr string
command string
timeoutSec int
maxRetry int
retryGapSec int
isEnable int
remark sql.NullString
taskStatus string
)
if err := rows.Scan(&id, &taskName, &cronExpr, &command,
&timeoutSec, &maxRetry, &retryGapSec, &isEnable, &remark, &taskStatus); err != nil {
return nil, err
}
remarkVal := ""
if remark.Valid {
remarkVal = remark.String
}
list = append(list, &domain.TaskEntity{
TaskID: id,
TaskName: taskName,
CronExpr: cronExpr,
Command: command,
TimeoutSec: timeoutSec,
MaxRetry: maxRetry,
RetryGapSec: retryGapSec,
IsEnable: isEnable == 1,
Remark: remarkVal,
TaskStatus: domain.ParseTaskStatus(taskStatus),
})
}
return list, rows.Err()
}
// GetByID 按任务ID查询单条
func (r *TaskRepo) GetByID(db *sql.DB, taskID int64) (*domain.TaskEntity, error) {
row := db.QueryRow(
SELECT id, task_name, cron_expr, command, timeout_sec, max_retry, retry_gap_sec, is_enable, remark, task_status FROM t_task WHERE id = ?, taskID)
entity, err := scanTask(row)
if errors.Is(err, sql.ErrNoRows) {
return nil, nil
}
return entity, err
}
// GetList 查询全部任务
func (r *TaskRepo) GetList(db *sql.DB) ([]*domain.TaskEntity, error) {
rows, err := db.Query(
SELECT id, task_name, cron_expr, command, timeout_sec, max_retry, retry_gap_sec, is_enable, remark, task_status FROM t_task ORDER BY id)
if err != nil {
return nil, err
}
return scanTasks(rows)
}
// GetEnabledList 只查询已启用的任务(调度线程使用)
func (r *TaskRepo) GetEnabledList(db *sql.DB) ([]*domain.TaskEntity, error) {
rows, err := db.Query(
SELECT id, task_name, cron_expr, command, timeout_sec, max_retry, retry_gap_sec, is_enable, remark, task_status FROM t_task WHERE is_enable = 1 ORDER BY id)
if err != nil {
return nil, err
}
return scanTasks(rows)
}
// Save 新增或更新任务;TaskID==0 为新增,否则更新
func (r *TaskRepo) Save(db *sql.DB, entity *domain.TaskEntity) (*domain.TaskEntity, error) {
if entity.TaskID == 0 {
// ---------- 新增 ----------
res, err := db.Exec(
INSERT INTO t_task (task_name, cron_expr, command, timeout_sec, max_retry, retry_gap_sec, is_enable, remark, task_status) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?),
entity.TaskName, entity.CronExpr, entity.Command,
entity.TimeoutSec, entity.MaxRetry, entity.RetryGapSec,
boolToInt(entity.IsEnable), entity.Remark, entity.TaskStatus.String())
if err != nil {
return nil, fmt.Errorf("插入任务失败: %w", err)
}
id, _ := res.LastInsertId()
entity.TaskID = id
return entity, nil
}
// ---------- 更新 ----------
_, err := db.Exec(
UPDATE t_task SET task_name=?, cron_expr=?, command=?, timeout_sec=?, max_retry=?, retry_gap_sec=?, is_enable=?, remark=?, task_status=? WHERE id=?,
entity.TaskName, entity.CronExpr, entity.Command,
entity.TimeoutSec, entity.MaxRetry, entity.RetryGapSec,
boolToInt(entity.IsEnable), entity.Remark, entity.TaskStatus.String(),
entity.TaskID)
if err != nil {
return nil, fmt.Errorf("更新任务失败: %w", err)
}
return entity, nil
}
// DeleteByID 按ID删除任务
func (r *TaskRepo) DeleteByID(db *sql.DB, taskID int64) error {
_, err := db.Exec(DELETE FROM t_task WHERE id = ?, taskID)
return err
}
// UpdateStatus 更新任务运行状态(调度线程使用)
func (r *TaskRepo) UpdateStatus(db *sql.DB, taskID int64, status domain.TaskStatus) error {
_, err := db.Exec(UPDATE t_task SET task_status = ? WHERE id = ?, status.String(), taskID)
return err
}
// UpdateEnable 更新任务启用/禁用状态(一次性任务自动禁用使用)
func (r *TaskRepo) UpdateEnable(db *sql.DB, taskID int64, enable bool) error {
_, err := db.Exec(UPDATE t_task SET is_enable = ? WHERE id = ?, boolToInt(enable), taskID)
return err
}
// boolToInt Go布尔转SQLite整数(1/0)
func boolToInt(b bool) int {
if b {
return 1
}
return 0
}
/*
版权所有 2026 ©涂聚文有限公司™ ®
许可信息查看:言語成了邀功盡責的功臣,還需要行爲每日來值班嗎
描述: Algorithms
Author : geovindu,Geovin Du 涂聚文.
IDE : goLang 2024.3.6 go 26.2
os : windows 10
database : mysql 9.0 sql server 2019, postgreSQL 17.0 Oracle 21c Neo4j
Datetime : 2026/7/8 23:07
User : geovindu
Product : GoLand
Project : goalgorithms
File : subprocess_runner.go
/
/
runner/subprocess_runner.go
子进程命令执行器
功能:调用系统shell执行命令,超时控制,捕获stdout+stderr合并输出
对应 Python 版 infrastructure/task_subprocess_runner.py
Windows 使用 cmd /C,Linux/macOS 使用 sh -c
*/
package runner
import (
"bytes"
"context"
"fmt"
"log"
"os/exec"
"runtime"
"time"
)
// SubprocessRunner 子进程执行器
type SubprocessRunner struct {
TimeoutSec int // 超时秒数
}
// NewSubprocessRunner 创建执行器
func NewSubprocessRunner(timeoutSec int) *SubprocessRunner {
return &SubprocessRunner{TimeoutSec: timeoutSec}
}
// RunResult 执行结果
type RunResult struct {
Success bool // 是否成功(退出码0)
Output string // 合并输出文本(stdout+stderr)
}
// Run 执行命令
// 参数 taskID: 任务ID(仅用于日志)
// 参数 command: 待执行命令字符串
// 返回值: 执行结果
func (r *SubprocessRunner) Run(taskID int64, command string) *RunResult {
// ---------- 按平台选择 shell ----------
var cmd *exec.Cmd
if runtime.GOOS == "windows" {
// Windows: cmd /C "命令"
cmd = exec.Command("cmd", "/C", command)
} else {
// Linux/macOS: sh -c "命令"
cmd = exec.Command("sh", "-c", command)
}
// ---------- 超时控制 ----------
timeout := time.Duration(r.TimeoutSec) * time.Second
ctx, cancel := context.WithTimeout(context.Background(), timeout)
defer cancel()
cmd = exec.CommandContext(ctx, cmd.Path, cmd.Args[1:]...)
var outBuf, errBuf bytes.Buffer
cmd.Stdout = &outBuf
cmd.Stderr = &errBuf
err := cmd.Run()
fullOutput := outBuf.String() + errBuf.String()
// ---------- 超时处理 ----------
if ctx.Err() == context.DeadlineExceeded {
msg := fmt.Sprintf("【超时】任务task_id=%d超过%d秒被强制终止", taskID, r.TimeoutSec)
log.Println(msg)
return &RunResult{Success: false, Output: msg}
}
// ---------- 正常结束 / 异常 ----------
if err != nil {
// 退出码非0或启动失败
exitCode := 1
if exitErr, ok := err.(*exec.ExitError); ok {
exitCode = exitErr.ExitCode()
}
return &RunResult{
Success: false,
Output: fmt.Sprintf("返回码:%d\n%s", exitCode, fullOutput),
}
}
return &RunResult{Success: true, Output: fullOutput}
}
/*
版权所有 2026 ©涂聚文有限公司™ ®
许可信息查看:言語成了邀功盡責的功臣,還需要行爲每日來值班嗎
描述: Algorithms
Author : geovindu,Geovin Du 涂聚文.
IDE : goLang 2024.3.6 go 26.2
os : windows 10
database : mysql 9.0 sql server 2019, postgreSQL 17.0 Oracle 21c Neo4j
Datetime : 2026/7/8 23:07
User : geovindu
Product : GoLand
Project : goalgorithms
File : background_scheduler.go
/
/
scheduler/background_scheduler.go
主调度后台线程
职责:
定时扫描启用任务
cron时间判断(robfig/cron解析,下次触发时间 <= 当前时间才执行)
并发数量控制(信号量 + 运行标记)
调用应用层执行任务、更新任务运行状态
一次性任务(备注含"一次性")执行成功后自动禁用
对应 Python 版 infrastructure/scheduler_background_thread.py
*/
package scheduler
import (
"log"
"strings"
"sync"
"time"
"github.com/robfig/cron/v3"
"taskscheduler/application"
"taskscheduler/config"
"taskscheduler/domain"
"taskscheduler/repository"
"taskscheduler/storage"
)
// BackgroundScheduler 后台调度线程
type BackgroundScheduler struct {
stopCh chan struct{} // 停止信号
bootTime time.Time // 程序启动时间,作为"从未执行任务"的调度基准
lastRunMap map[int64]time.Time // 任务上次执行时间 {taskID: time}
runningIDs map[int64]bool // 当前正在运行的任务ID集合
mu sync.Mutex // 并发保护锁
taskRepo *repository.TaskRepo // 任务仓储
service *application.TaskSchedulerService // 应用服务
maxConcurrent int // 最大并发数
scanInterval time.Duration // 扫描间隔
}
// NewBackgroundScheduler 创建调度线程
func NewBackgroundScheduler() *BackgroundScheduler {
return &BackgroundScheduler{
stopCh: make(chan struct{}),
bootTime: time.Now(),
lastRunMap: make(map[int64]time.Time),
runningIDs: make(map[int64]bool),
taskRepo: repository.NewTaskRepo(),
service: application.NewTaskSchedulerService(),
maxConcurrent: config.Cfg.Scheduler.ConcurrentMax,
scanInterval: time.Duration(config.Cfg.Scheduler.ScanIntervalMS) * time.Millisecond,
}
}
// Stop 停止调度线程(程序退出调用)
func (s *BackgroundScheduler) Stop() {
close(s.stopCh)
}
// isRunning 判断任务是否正在运行
func (s *BackgroundScheduler) isRunning(taskID int64) bool {
s.mu.Lock()
defer s.mu.Unlock()
return s.runningIDs[taskID]
}
// markRunning 标记任务运行中,返回是否成功占用
func (s *BackgroundScheduler) markRunning(taskID int64) bool {
s.mu.Lock()
defer s.mu.Unlock()
if s.runningIDs[taskID] {
return false
}
s.runningIDs[taskID] = true
return true
}
// unmarkRunning 释放任务运行标记
func (s *BackgroundScheduler) unmarkRunning(taskID int64) {
s.mu.Lock()
defer s.mu.Unlock()
delete(s.runningIDs, taskID)
}
// canRunTask 判断该任务当前时刻是否应执行
// 规则:cron 下一次触发时间 <= 当前时间才执行
// 有上次执行时间 → 从上次执行时间往后找下一次触发点
// 从未执行过 → 从程序启动时间往后找下一次触发点(不补跑错过的历史点)
func (s *BackgroundScheduler) canRunTask(task *domain.TaskEntity, now time.Time) bool {
if !task.IsEnable {
return false
}
if s.isRunning(task.TaskID) {
return false
}
// 基准时间:优先上次执行时间,否则程序启动时间
s.mu.Lock()
lastDt, hasLast := s.lastRunMap[task.TaskID]
s.mu.Unlock()
base := s.bootTime
if hasLast {
base = lastDt
}
// robfig/cron 解析标准5字段cron表达式,并计算下一次触发时间
sched, err := cron.ParseStandard(task.CronExpr)
if err != nil {
log.Printf("cron表达式解析失败 task_id=%d cron=%s err=%v", task.TaskID, task.CronExpr, err)
return false
}
next := sched.Next(base)
// 只有到达/超过下一次触发时间才执行
return !now.Before(next)
}
// isOneTimeTask 判断是否一次性任务(备注含"一次性")
func isOneTimeTask(remark string) bool {
return len(remark) > 0 && strings.Contains(remark, "一次性")
}
// worker 单个任务工作协程:执行任务,结束后更新状态、释放并发标记
// 一次性任务(备注含"一次性")执行完成后自动禁用
func (s *BackgroundScheduler) worker(task *domain.TaskEntity, wg *sync.WaitGroup, sem chan struct{}) {
defer wg.Done()
defer func() { <-sem }()
defer s.unmarkRunning(task.TaskID)
// 1. 更新数据库任务状态为 RUNNING
if err := s.taskRepo.UpdateStatus(storage.DB, task.TaskID, domain.TaskStatusRUNNING); err != nil {
log.Printf("更新任务状态RUNNING失败 task_id=%d err=%v", task.TaskID, err)
}
// 2. 调用应用层执行任务(子进程执行、写执行历史)
_, _ = s.service.ExecuteSingleTask(task)
// 3. 恢复任务状态为 IDLE
if err := s.taskRepo.UpdateStatus(storage.DB, task.TaskID, domain.TaskStatusIDLE); err != nil {
log.Printf("更新任务状态IDLE失败 task_id=%d err=%v", task.TaskID, err)
}
// 4. 记录本次执行时间,供下次 cron 判断
s.mu.Lock()
s.lastRunMap[task.TaskID] = time.Now()
s.mu.Unlock()
// 5. 一次性任务执行成功后自动禁用(真正"只执行一次")
if isOneTimeTask(task.Remark) {
if err := s.taskRepo.UpdateEnable(storage.DB, task.TaskID, false); err != nil {
log.Printf("一次性任务自动禁用失败 task_id=%d err=%v", task.TaskID, err)
} else {
log.Printf("一次性任务 task_id=%d【%s】执行完成,已自动禁用,不再调度",
task.TaskID, task.TaskName)
}
}
}
// scanOnce 单次调度扫描逻辑
func (s *BackgroundScheduler) scanOnce(now time.Time) {
// 1. 获取全部启用任务
tasks, err := s.taskRepo.GetEnabledList(storage.DB)
if err != nil {
log.Printf("获取启用任务失败: %v", err)
return
}
// 2. 筛选满足 cron 触发条件的任务
var pending []*domain.TaskEntity
for _, t := range tasks {
if s.canRunTask(t, now) {
pending = append(pending, t)
}
}
if len(pending) == 0 {
return
}
// 3. 并发控制:信号量限制最大并发数
sem := make(chan struct{}, s.maxConcurrent)
var wg sync.WaitGroup
for _, t := range pending {
if !s.markRunning(t.TaskID) {
continue // 已被其他协程占用
}
wg.Add(1)
sem <- struct{}{}
go s.worker(t, &wg, sem)
}
wg.Wait()
}
// Run 调度主循环(阻塞运行,直到 Stop 被调用)
func (s *BackgroundScheduler) Run() {
log.Printf("调度主线程启动,扫描间隔=%v,最大并发=%d", s.scanInterval, s.maxConcurrent)
ticker := time.NewTicker(s.scanInterval)
defer ticker.Stop()
for {
select {
case <-s.stopCh:
log.Println("调度主线程已退出")
return
case now := <-ticker.C:
s.scanOnce(now)
}
}
}
/*
版权所有 2026 ©涂聚文有限公司™ ®
许可信息查看:言語成了邀功盡責的功臣,還需要行爲每日來值班嗎
描述: Algorithms
Author : geovindu,Geovin Du 涂聚文.
IDE : goLang 2024.3.6 go 26.2
os : windows 10
database : mysql 9.0 sql server 2019, postgreSQL 17.0 Oracle 21c Neo4j
Datetime : 2026/7/8 23:07
User : geovindu
Product : GoLand
Project : goalgorithms
File : db.go
*/
/*
storage/db.go
数据库层:SQLite连接、建表、时间格式化工具
对应 Python 版 infrastructure/db_session.py + db_init.py
使用 modernc.org/sqlite(纯Go驱动,无需CGO)
*/
package storage
import (
"database/sql"
"fmt"
"time"
_ "modernc.org/sqlite"
)
// DB 全局数据库连接句柄
var DB *sql.DB
// TimeLayout 数据库时间存储格式
const TimeLayout = "2006-01-02 15:04:05"
// InitDB 初始化SQLite数据库并创建全部数据表
// 参数 dbFile: 数据库文件路径
// 返回值: 错误
func InitDB(dbFile string) error {
// dsn: 纯Go驱动连接串,busy_timeout 避免并发写锁冲突
dsn := fmt.Sprintf("file:%s?_pragma=busy_timeout(5000)&_pragma=journal_mode(WAL)", dbFile)
db, err := sql.Open("sqlite", dsn)
if err != nil {
return fmt.Errorf("打开数据库失败: %w", err)
}
// 测试连接
if err := db.Ping(); err != nil {
return fmt.Errorf("数据库连接测试失败: %w", err)
}
DB = db
// ---------- 建表:任务表 ----------
createTaskSQL := `
CREATE TABLE IF NOT EXISTS t_task (
id INTEGER PRIMARY KEY AUTOINCREMENT,
task_name TEXT NOT NULL,
cron_expr TEXT NOT NULL,
command TEXT NOT NULL,
timeout_sec INTEGER NOT NULL DEFAULT 300,
max_retry INTEGER NOT NULL DEFAULT 0,
retry_gap_sec INTEGER NOT NULL DEFAULT 10,
is_enable INTEGER NOT NULL DEFAULT 1,
remark TEXT,
task_status TEXT NOT NULL DEFAULT 'IDLE'
);`
if _, err := db.Exec(createTaskSQL); err != nil {
return fmt.Errorf("创建任务表失败: %w", err)
}
// ---------- 建表:执行历史表 ----------
createHistorySQL := `
CREATE TABLE IF NOT EXISTS t_task_history (
id INTEGER PRIMARY KEY AUTOINCREMENT,
task_id INTEGER NOT NULL,
execute_start_time TEXT NOT NULL,
execute_end_time TEXT NOT NULL,
is_success INTEGER NOT NULL DEFAULT 0,
execute_msg TEXT
);
CREATE INDEX IF NOT EXISTS idx_history_task_id ON t_task_history(task_id);`
if _, err := db.Exec(createHistorySQL); err != nil {
return fmt.Errorf("创建历史表失败: %w", err)
}
return nil
}
// FormatTime 时间转数据库存储字符串
func FormatTime(t time.Time) string {
return t.Format(TimeLayout)
}
// ParseTime 数据库字符串转时间
func ParseTime(s string) (time.Time, error) {
return time.ParseInLocation(TimeLayout, s, time.Local)
}
// Close 关闭数据库连接
func Close() {
if DB != nil {
_ = DB.Close()
}
}
/*
application/task_scheduler_service.go
应用层业务服务:任务执行入口
职责:调用子进程执行命令、失败重试、组装历史实体、写入历史仓储
对应 Python 版 application/task_scheduler_app_service.py
*/
package application
import (
"log"
"time"
"taskscheduler/domain"
"taskscheduler/repository"
"taskscheduler/runner"
"taskscheduler/storage"
)
// TaskSchedulerService 应用层任务调度服务
type TaskSchedulerService struct {
HistoryRepo *repository.TaskHistoryRepo // 历史仓储
}
// NewTaskSchedulerService 创建应用服务实例
func NewTaskSchedulerService() *TaskSchedulerService {
return &TaskSchedulerService{
HistoryRepo: repository.NewTaskHistoryRepo(),
}
}
// ExecuteSingleTask 执行单个任务(手动/调度共用)
// 包含:子进程执行 → 失败按 MaxRetry/RetryGapSec 重试 → 写入执行历史
// 参数 task: 任务领域实体
// 返回值: 本次执行历史实体
func (s *TaskSchedulerService) ExecuteSingleTask(task *domain.TaskEntity) (*domain.TaskHistoryEntity, error) {
log.Printf("开始执行任务 task_id=%d 任务名称=%s", task.TaskID, task.TaskName)
startTime := time.Now()
execRunner := runner.NewSubprocessRunner(task.TimeoutSec)
var finalSuccess bool
var finalMsg string
currentRetry := 0
// ---------- 执行 + 重试循环 ----------
for {
result := execRunner.Run(task.TaskID, task.Command)
if result.Success {
finalSuccess = true
finalMsg = result.Output
break
}
// 失败:记录本次输出
finalMsg = result.Output
currentRetry++
if currentRetry > task.MaxRetry {
break
}
log.Printf("task_id=%d 执行失败,第 %d 次重试,间隔 %d 秒",
task.TaskID, currentRetry, task.RetryGapSec)
time.Sleep(time.Duration(task.RetryGapSec) * time.Second)
}
endTime := time.Now()
// ---------- 组装历史实体 ----------
history := &domain.TaskHistoryEntity{
HistoryID: 0, // 0=新增,数据库自增
TaskID: task.TaskID,
ExecuteStartTime: startTime,
ExecuteEndTime: endTime,
IsSuccess: finalSuccess,
ExecuteMsg: finalMsg,
}
// ---------- 写入历史仓储 ----------
saved, err := s.HistoryRepo.Insert(storage.DB, history)
if err != nil {
log.Printf("保存任务历史失败 task_id=%d: %v", task.TaskID, err)
return nil, err
}
if finalSuccess {
log.Printf("任务执行成功 task_id=%d", task.TaskID)
} else {
log.Printf("任务执行失败 task_id=%d,重试次数=%d,output=%s",
task.TaskID, currentRetry, finalMsg)
}
return saved, nil
}
Go
/*
# 版权所有 2026 ©涂聚文有限公司™ ®
# 许可信息查看:言語成了邀功盡責的功臣,還需要行爲每日來值班嗎
# 描述: Algorithms
# Author : geovindu,Geovin Du 涂聚文.
# IDE : goLang 2024.3.6 go 26.2
# os : windows 10
# database : mysql 9.0 sql server 2019, postgreSQL 17.0 Oracle 21c Neo4j
# Datetime : 2026/7/8 23:07
# User : geovindu
# Product : GoLand
# Project : goalgorithms
# File : daily_chart.go
*/
/*
gui/daily_chart.go
近N天执行次数柱状图:walk CustomWidget 自绘
用 canvas 绘制:Y轴基线、柱子、柱顶数值、日期标签
数据来源:repository.CountByDay
*/
package main
import (
"fmt"
"github.com/lxn/walk"
"taskscheduler/repository"
)
// drawDailyChart 绘制近N天执行次数柱状图
// 参数 canvas: 绘图画布;bounds: 控件客户区;data: 按天统计数据
func drawDailyChart(canvas *walk.Canvas, bounds walk.Rectangle, data []repository.DayCount) error {
if len(data) == 0 {
return nil
}
// 布局边距(逻辑单位)
const (
left, right = 34, 10
top, bottom = 20, 24
titleH = 16
)
plotW := bounds.Width - left - right
plotH := bounds.Height - top - bottom
if plotW < 20 || plotH < 20 {
return nil
}
// 颜色与字体
barBlue := walk.RGB(45, 91, 255) // 柱体主色
lineGray := walk.RGB(200, 200, 200) // 轴线
textDark := walk.RGB(60, 60, 60) // 文字
font, err := walk.NewFont("Microsoft YaHei", 9, 0) // 0=FontRegular普通样式
if err != nil {
return err
}
// 标题
_ = canvas.DrawText("近7天执行次数", font, textDark,
walk.Rectangle{X: left, Y: 0, Width: 200, Height: titleH}, walk.TextLeft)
// 最大值(至少1,避免除0)
var maxVal int64 = 1
for _, d := range data {
if d.Count > maxVal {
maxVal = d.Count
}
}
// Y轴与X轴基线
pen, err := walk.NewCosmeticPen(walk.PenSolid, lineGray)
if err != nil {
return err
}
origin := walk.Point{X: left, Y: top + plotH}
_ = canvas.DrawLine(pen, walk.Point{X: left, Y: top}, origin)
_ = canvas.DrawLine(pen, origin, walk.Point{X: left + plotW, Y: top + plotH})
// Y轴最大值刻度
_ = canvas.DrawText(fmt.Sprintf("%d", maxVal), font, textDark,
walk.Rectangle{X: 0, Y: top - 6, Width: left - 6, Height: 14}, walk.TextRight)
// 柱体
brush, err := walk.NewSolidColorBrush(barBlue)
if err != nil {
return err
}
slot := plotW / len(data)
barW := slot * 6 / 10
if barW < 6 {
barW = 6
}
for i, d := range data {
h := int(float64(d.Count) / float64(maxVal) * float64(plotH))
if d.Count > 0 && h < 2 {
h = 2 // 至少画2像素高度
}
x0 := left + i*slot + (slot-barW)/2
y0 := top + plotH - h
// 柱体
if err := canvas.FillRectangle(brush,
walk.Rectangle{X: x0, Y: y0, Width: barW, Height: h}); err != nil {
return err
}
// 柱顶数值(空间不足时画在柱内)
labelRect := walk.Rectangle{X: x0 - 12, Y: y0 - 15, Width: barW + 24, Height: 13}
if y0-15 < top {
labelRect.Y = y0 + 2
}
if d.Count > 0 {
_ = canvas.DrawText(fmt.Sprintf("%d", d.Count), font, textDark, labelRect, walk.TextLeft)
}
// 日期标签(MM-DD)
dateLabel := d.Date[5:]
_ = canvas.DrawText(dateLabel, font, textDark,
walk.Rectangle{X: x0 - 14, Y: top + plotH + 4, Width: slot, Height: 16}, walk.TextLeft)
}
return nil
}
/*
# 版权所有 2026 ©涂聚文有限公司™ ®
# 许可信息查看:言語成了邀功盡責的功臣,還需要行爲每日來值班嗎
# 描述: Algorithms
# Author : geovindu,Geovin Du 涂聚文.
# IDE : goLang 2024.3.6 go 26.2
# os : windows 10
# database : mysql 9.0 sql server 2019, postgreSQL 17.0 Oracle 21c Neo4j
# Datetime : 2026/7/8 23:07
# User : geovindu
# Product : GoLand
# Project : goalgorithms
# File : dashboard_view.go
*/
/*
gui/dashboard_view.go
仪表盘视图:任务统计卡片 + 执行统计 + 最近执行记录
对应 Python 版 presentation/dashboard_view.py(含定时自动刷新)
统计指标:任务总数/启用/禁用/一次性、执行总次数/成功/失败/成功率、今日执行/最近执行
*/
package main
import (
"strconv"
"strings"
"github.com/lxn/walk"
. "github.com/lxn/walk/declarative"
"taskscheduler/repository"
"taskscheduler/storage"
)
// DashboardView 仪表盘视图
type DashboardView struct {
root *walk.Composite // 仪表盘根容器
// ---------- 任务统计标签 ----------
lblTotalTask *walk.Label // 任务总数
lblEnableTask *walk.Label // 启用任务
lblDisableTask *walk.Label // 禁用任务
lblOneTimeTask *walk.Label // 一次性任务
// ---------- 执行统计标签 ----------
lblTotalRun *walk.Label // 执行总次数
lblSuccRun *walk.Label // 成功次数
lblFailRun *walk.Label // 失败次数
lblSuccRate *walk.Label // 成功率
lblTodayRun *walk.Label // 今日执行
lblLastRun *walk.Label // 最近执行时间
// ---------- 最近执行记录 ----------
historyModel *HistoryTableModel // 历史表格模型
historyTable *walk.TableView // 历史表格
// ---------- 近7天柱状图 ----------
chart *walk.CustomWidget // 柱状图控件
dailyData []repository.DayCount // 柱状图数据
}
// newDashboardView 创建仪表盘视图实例
func newDashboardView() *DashboardView {
return &DashboardView{historyModel: &HistoryTableModel{}}
}
// statCard 构建单个统计卡片(标题 + 大号数值)
func statCard(title string, valueLabel **walk.Label) Widget {
return Composite{
Layout: VBox{MarginsZero: false},
Children: []Widget{
Label{Text: title, Alignment: AlignHCenterVNear},
Label{
AssignTo: valueLabel,
Text: "0",
Font: Font{PointSize: 22, Bold: true},
Alignment: AlignHCenterVFar,
},
},
}
}
// page 返回仪表盘Tab页的declarative定义
func (d *DashboardView) page() TabPage {
return TabPage{
Title: "仪表盘",
Layout: VBox{},
Children: []Widget{
Composite{
Layout: HBox{},
Children: []Widget{
Label{Text: "任务与执行统计(每5秒自动刷新)"},
VSpacer{},
PushButton{Text: "立即刷新", OnClicked: d.Refresh},
},
},
// 统计卡片区
Composite{
Layout: Grid{Columns: 4},
Children: []Widget{
statCard("任务总数", &d.lblTotalTask),
statCard("启用任务", &d.lblEnableTask),
statCard("禁用任务", &d.lblDisableTask),
statCard("一次性任务", &d.lblOneTimeTask),
statCard("执行总次数", &d.lblTotalRun),
statCard("成功次数", &d.lblSuccRun),
statCard("失败次数", &d.lblFailRun),
statCard("成功率(%)", &d.lblSuccRate),
statCard("今日执行", &d.lblTodayRun),
statCard("最近执行", &d.lblLastRun),
},
},
Label{Text: "近7天执行次数柱状图:"},
// 柱状图(CustomWidget自绘,数据由Refresh更新后Invalidate重绘)
CustomWidget{
AssignTo: &d.chart,
ClearsBackground: true,
AlwaysConsumeSpace: true,
MinSize: Size{Height: 190},
Paint: func(canvas *walk.Canvas, bounds walk.Rectangle) error {
return drawDailyChart(canvas, bounds, d.dailyData)
},
},
Label{Text: "最近执行记录:"},
// 最近执行历史表格
TableView{
AssignTo: &d.historyTable,
Model: d.historyModel,
Columns: []TableViewColumn{
{Title: "历史ID", Width: 70},
{Title: "任务ID", Width: 70},
{Title: "执行时间", Width: 150},
{Title: "结果", Width: 60},
{Title: "输出摘要", Width: 420},
},
},
},
}
}
// Refresh 刷新仪表盘全部统计与最近记录
func (d *DashboardView) Refresh() {
// ---------- 1. 任务统计 ----------
tasks, err := taskRepo.GetList(storage.DB)
if err != nil {
return
}
totalTask, enableTask, oneTimeTask := 0, 0, 0
for _, t := range tasks {
totalTask++
if t.IsEnable {
enableTask++
}
if len(t.Remark) > 0 && containsOneTime(t.Remark) {
oneTimeTask++
}
}
d.lblTotalTask.SetText(strconv.Itoa(totalTask))
d.lblEnableTask.SetText(strconv.Itoa(enableTask))
d.lblDisableTask.SetText(strconv.Itoa(totalTask - enableTask))
d.lblOneTimeTask.SetText(strconv.Itoa(oneTimeTask))
// ---------- 2. 执行统计 ----------
totalRun, succRun, failRun, err := historyRepo.CountStats(storage.DB)
if err != nil {
return
}
d.lblTotalRun.SetText(strconv.FormatInt(totalRun, 10))
d.lblSuccRun.SetText(strconv.FormatInt(succRun, 10))
d.lblFailRun.SetText(strconv.FormatInt(failRun, 10))
rate := 0.0
if totalRun > 0 {
rate = float64(succRun) / float64(totalRun) * 100
}
d.lblSuccRate.SetText(strconv.FormatFloat(rate, 'f', 1, 64))
todayRun, err := historyRepo.CountToday(storage.DB)
if err == nil {
d.lblTodayRun.SetText(strconv.FormatInt(todayRun, 10))
}
// ---------- 3. 近7天柱状图数据 ----------
daily, err := historyRepo.CountByDay(storage.DB, 7)
if err == nil {
d.dailyData = daily
if d.chart != nil {
d.chart.Invalidate() // 触发重绘
}
}
// ---------- 4. 最近执行记录 ----------
recent, err := historyRepo.ListRecent(storage.DB, 10)
if err == nil {
d.historyModel.SetItems(recent)
}
// 最近执行时间(最新一条的开始时间)
if len(recent) > 0 {
d.lblLastRun.SetText(recent[0].ExecuteStartTime.Format("01-02 15:04"))
}
}
// containsOneTime 判断备注是否含"一次性"(与调度线程判定一致)
func containsOneTime(remark string) bool {
return strings.Contains(remark, "一次性")
}
// 编译期断言:HistoryTableModel 用于仪表盘与历史对话框共用
var _ walk.TableModel = (*HistoryTableModel)(nil)
/*
# 版权所有 2026 ©涂聚文有限公司™ ®
# 许可信息查看:言語成了邀功盡責的功臣,還需要行爲每日來值班嗎
# 描述: Algorithms
# Author : geovindu,Geovin Du 涂聚文.
# IDE : goLang 2024.3.6 go 26.2
# os : windows 10
# database : mysql 9.0 sql server 2019, postgreSQL 17.0 Oracle 21c Neo4j
# Datetime : 2026/7/8 23:07
# User : geovindu
# Product : GoLand
# Project : goalgorithms
# File : gui_log_writer.go
*/
/*
gui/gui_log_writer.go
GUI日志写入器:实现 io.Writer,把日志行写入主窗口日志文本框
线程安全:后台线程写日志时通过 Synchronize 调度到UI线程追加文本
*/
package main
import (
"sync"
"github.com/lxn/walk"
)
// GuiLogWriter GUI日志写入器
type GuiLogWriter struct {
mainWin *MainWindowX // 主窗口引用(持有日志文本框)
mu sync.Mutex // 并发保护锁
}
// NewGuiLogWriter 创建GUI日志写入器
func NewGuiLogWriter(mw *MainWindowX) *GuiLogWriter {
return &GuiLogWriter{mainWin: mw}
}
// Write 实现 io.Writer 接口,追加日志文本到GUI文本框
func (w *GuiLogWriter) Write(p []byte) (int, error) {
w.mu.Lock()
defer w.mu.Unlock()
// 窗口尚未创建完成(InitWithWriter阶段)时跳过GUI输出,
// 此时仍正常写入控制台与文件,不影响日志记录
if w.mainWin == nil || w.mainWin.MainWindow == nil {
return len(p), nil
}
// 通过 Synchronize 保证在UI线程执行文本追加
w.mainWin.Synchronize(func() {
if w.mainWin.logEdit != nil {
w.mainWin.logEdit.AppendText(string(p))
}
})
return len(p), nil
}
// 确保 MainWindowX 继承自 walk.MainWindow(含 Synchronize 方法)
var _ walk.Form = (*MainWindowX)(nil)
/*
# 版权所有 2026 ©涂聚文有限公司™ ®
# 许可信息查看:言語成了邀功盡責的功臣,還需要行爲每日來值班嗎
# 描述: Algorithms
# Author : geovindu,Geovin Du 涂聚文.
# IDE : goLang 2024.3.6 go 26.2
# os : windows 10
# database : mysql 9.0 sql server 2019, postgreSQL 17.0 Oracle 21c Neo4j
# Datetime : 2026/7/8 23:07
# User : geovindu
# Product : GoLand
# Project : goalgorithms
# File : task_edit_dialog.go
*/
/*
gui/task_edit_dialog.go
任务新增/编辑对话框:表单字段、校验、组装领域实体
对应 Python 版 presentation/task_edit_dialog.py
新建:传入 nil;编辑:传入已有 TaskEntity
*/
package main
import (
"strconv"
"strings"
"github.com/lxn/walk"
. "github.com/lxn/walk/declarative"
"taskscheduler/domain"
"taskscheduler/repository"
"taskscheduler/storage"
)
// showTaskEditDialog 显示任务新增/编辑对话框
// 参数 owner: 父窗口;entity: nil=新增,非nil=编辑
// 返回值: 保存后的任务实体;取消返回 nil
func showTaskEditDialog(owner walk.Form, entity *domain.TaskEntity) *domain.TaskEntity {
var dlg *walk.Dialog
var edName, edCron, edCmd, edTimeout, edRetry, edGap *walk.LineEdit
var chkEnable *walk.CheckBox
var edRemark *walk.TextEdit
var acceptPB, cancelPB *walk.PushButton
// 初始值:编辑模式回显,新增模式给默认值
name, cron, cmd, remark := "", "", "", ""
timeout, retry, gap := "300", "0", "10"
enable := true
if entity != nil {
name, cron, cmd = entity.TaskName, entity.CronExpr, entity.Command
remark = entity.Remark
timeout = strconv.Itoa(entity.TimeoutSec)
retry = strconv.Itoa(entity.MaxRetry)
gap = strconv.Itoa(entity.RetryGapSec)
enable = entity.IsEnable
}
title := "新增任务"
if entity != nil {
title = "编辑任务"
}
// 声明式构建对话框
dlgDecl := Dialog{
AssignTo: &dlg,
Title: title,
MinSize: Size{Width: 540, Height: 520},
Layout: VBox{},
Children: []Widget{
Composite{
Layout: Grid{Columns: 2},
Children: []Widget{
Label{Text: "任务名称:"},
LineEdit{AssignTo: &edName, Text: name},
Label{Text: "Cron表达式:"},
LineEdit{AssignTo: &edCron, Text: cron},
Label{Text: "执行命令:"},
LineEdit{AssignTo: &edCmd, Text: cmd},
Label{Text: "超时(秒):"},
LineEdit{AssignTo: &edTimeout, Text: timeout},
Label{Text: "最大重试次数:"},
LineEdit{AssignTo: &edRetry, Text: retry},
Label{Text: "重试间隔(秒):"},
LineEdit{AssignTo: &edGap, Text: gap},
},
},
Composite{
Layout: HBox{},
Children: []Widget{
CheckBox{AssignTo: &chkEnable, Text: "启用任务", Checked: enable},
},
},
Label{Text: "备注(含\"一次性\"则执行成功后自动禁用):"},
TextEdit{AssignTo: &edRemark, Text: remark, MinSize: Size{Width: 500, Height: 80}},
Composite{
Layout: HBox{},
Children: []Widget{
HSpacer{},
PushButton{
AssignTo: &acceptPB,
Text: "确定",
OnClicked: func() { dlg.Accept() },
},
PushButton{
AssignTo: &cancelPB,
Text: "取消",
OnClicked: func() { dlg.Cancel() },
},
},
},
},
DefaultButton: &acceptPB,
CancelButton: &cancelPB,
}
// 运行对话框;返回 walk.DlgCmdOK 表示确定
cmdRet, err := dlgDecl.Run(owner)
if err != nil {
showError(owner, "对话框错误", err.Error())
return nil
}
if cmdRet != walk.DlgCmdOK {
return nil
}
// ---------- 表单校验 ----------
nameVal := strings.TrimSpace(edName.Text())
cronVal := strings.TrimSpace(edCron.Text())
cmdVal := strings.TrimSpace(edCmd.Text())
if nameVal == "" {
showWarning(owner, "校验", "任务名称不能为空")
return nil
}
if cronVal == "" {
showWarning(owner, "校验", "Cron表达式不能为空")
return nil
}
if cmdVal == "" {
showWarning(owner, "校验", "执行命令不能为空")
return nil
}
timeoutVal, err1 := strconv.Atoi(strings.TrimSpace(edTimeout.Text()))
retryVal, err2 := strconv.Atoi(strings.TrimSpace(edRetry.Text()))
gapVal, err3 := strconv.Atoi(strings.TrimSpace(edGap.Text()))
if err1 != nil || err2 != nil || err3 != nil {
showError(owner, "参数错误", "超时、重试次数、重试间隔必须为数字")
return nil
}
// ---------- 组装领域实体 ----------
newEntity := &domain.TaskEntity{
TaskID: 0,
TaskName: nameVal,
CronExpr: cronVal,
Command: cmdVal,
TimeoutSec: timeoutVal,
MaxRetry: retryVal,
RetryGapSec: gapVal,
IsEnable: chkEnable.Checked(),
Remark: strings.TrimSpace(edRemark.Text()),
TaskStatus: domain.TaskStatusIDLE,
}
if entity != nil {
newEntity.TaskID = entity.TaskID
newEntity.TaskStatus = entity.TaskStatus
}
// ---------- 保存到数据库 ----------
repo := repository.NewTaskRepo()
saved, err := repo.Save(storage.DB, newEntity)
if err != nil {
showError(owner, "保存失败", err.Error())
return nil
}
return saved
}
/*
# 版权所有 2026 ©涂聚文有限公司™ ®
# 许可信息查看:言語成了邀功盡責的功臣,還需要行爲每日來值班嗎
# 描述: Algorithms
# Author : geovindu,Geovin Du 涂聚文.
# IDE : goLang 2024.3.6 go 26.2
# os : windows 10
# database : mysql 9.0 sql server 2019, postgreSQL 17.0 Oracle 21c Neo4j
# Datetime : 2026/7/8 23:07
# User : geovindu
# Product : GoLand
# Project : goalgorithms
# File : task_history_dialog.go
*/
/*
gui/task_history_dialog.go
任务执行历史对话框:展示指定任务的执行历史列表
对应 Python 版 presentation/task_history_detail_dialog.py
*/
package main
import (
"fmt"
"github.com/lxn/walk"
. "github.com/lxn/walk/declarative"
"taskscheduler/domain"
"taskscheduler/repository"
"taskscheduler/storage"
)
// HistoryTableModel 历史表格模型
type HistoryTableModel struct {
walk.TableModelBase
items []*domain.TaskHistoryEntity // 历史数据切片
}
// RowCount 返回行数
func (m *HistoryTableModel) RowCount() int {
return len(m.items)
}
// Value 返回指定行列的值
func (m *HistoryTableModel) Value(row, col int) interface{} {
if row < 0 || row >= len(m.items) {
return ""
}
h := m.items[row]
switch col {
case 0:
return h.HistoryID
case 1:
return h.ExecuteStartTime.Format("2006-01-02 15:04:05")
case 2:
if h.IsSuccess {
return "成功"
}
return "失败"
case 3:
msg := h.ExecuteMsg
if len(msg) > 50 {
msg = msg[:50] + "..."
}
return msg
}
return ""
}
// SetItems 更新模型数据
func (m *HistoryTableModel) SetItems(items []*domain.TaskHistoryEntity) {
m.items = items
m.PublishRowsReset()
}
// showHistoryDialog 显示任务历史对话框
// 参数 owner: 父窗口;taskID: 任务ID;taskName: 任务名称
func showHistoryDialog(owner walk.Form, taskID int64, taskName string) {
// 查询历史数据
repo := repository.NewTaskHistoryRepo()
histories, err := repo.ListByTaskID(storage.DB, taskID, 100)
if err != nil {
showError(owner, "查询历史失败", err.Error())
return
}
model := &HistoryTableModel{}
model.SetItems(histories)
var dlg *walk.Dialog
var tv *walk.TableView
// 声明式构建历史对话框
dlgDecl := Dialog{
AssignTo: &dlg,
Title: fmt.Sprintf("执行历史 - %s(ID=%d)", taskName, taskID),
MinSize: Size{Width: 720, Height: 450},
Layout: VBox{},
Children: []Widget{
TableView{
AssignTo: &tv,
Model: model,
Columns: []TableViewColumn{
{Title: "历史ID", Width: 80},
{Title: "执行时间", Width: 160},
{Title: "结果", Width: 70},
{Title: "输出摘要", Width: 380},
},
},
Composite{
Layout: HBox{},
Children: []Widget{
HSpacer{},
PushButton{Text: "关闭", OnClicked: func() { dlg.Accept() }},
},
},
},
}
if _, err := dlgDecl.Run(owner); err != nil {
showError(owner, "打开历史对话框失败", err.Error())
}
}
/*
# 版权所有 2026 ©涂聚文有限公司™ ®
# 许可信息查看:言語成了邀功盡責的功臣,還需要行爲每日來值班嗎
# 描述: Algorithms
# Author : geovindu,Geovin Du 涂聚文.
# IDE : goLang 2024.3.6 go 26.2
# os : windows 10
# database : mysql 9.0 sql server 2019, postgreSQL 17.0 Oracle 21c Neo4j
# Datetime : 2026/7/8 23:07
# User : geovindu
# Product : GoLand
# Project : goalgorithms
# File : task_table_model.go
*/
/*
gui/task_table_model.go
任务表格模型:实现 walk.TableModel 接口,供主窗口 TableView 展示任务列表
列:ID/任务名称/Cron表达式/启用/状态/备注/下次执行
"下次执行"列:按标准cron语义从当前时间计算下一次触发时刻;
任务禁用时显示"-"(一次性任务已执行被禁用则显示"已完成")
对应 Python 版 presentation/task_view.py 的 Treeview 数据填充逻辑
*/
package main
import (
"time"
"github.com/lxn/walk"
"github.com/robfig/cron/v3"
"taskscheduler/domain"
)
// TaskTableModel 任务表格模型
type TaskTableModel struct {
walk.TableModelBase // 内嵌基类,提供 ResetRows 等基础能力
items []*domain.TaskEntity // 任务数据切片
}
// NewTaskTableModel 创建任务表格模型
func NewTaskTableModel() *TaskTableModel {
return &TaskTableModel{items: []*domain.TaskEntity{}}
}
// RowCount 返回行数(walk.TableModel 接口)
func (m *TaskTableModel) RowCount() int {
return len(m.items)
}
// Value 返回指定行列的值(walk.TableModel 接口)
func (m *TaskTableModel) Value(row, col int) interface{} {
if row < 0 || row >= len(m.items) {
return ""
}
item := m.items[row]
switch col {
case 0:
return item.TaskID
case 1:
return item.TaskName
case 2:
return item.CronExpr
case 3:
if item.IsEnable {
return "是"
}
return "否"
case 4:
return item.TaskStatus.String()
case 5:
return item.Remark
case 6:
return nextRunText(item)
}
return ""
}
// nextRunText 计算任务下次执行时间文本
// 规则:禁用任务→"-";cron非法→"无效cron";否则按标准cron语义求下次触发
func nextRunText(item *domain.TaskEntity) string {
if !item.IsEnable {
// 一次性任务执行完成后被自动禁用,标识为已完成
if containsOneTime(item.Remark) {
return "已完成"
}
return "-"
}
sched, err := cron.ParseStandard(item.CronExpr)
if err != nil {
return "无效cron"
}
next := sched.Next(time.Now())
return next.Format("01-02 15:04")
}
// SetItems 更新模型数据并通知表格刷新
func (m *TaskTableModel) SetItems(items []*domain.TaskEntity) {
m.items = items
m.PublishRowsReset()
}
// Items 返回当前数据(供外部读取选中行对应实体)
func (m *TaskTableModel) Items() []*domain.TaskEntity {
return m.items
}
/*
# 版权所有 2026 ©涂聚文有限公司™ ®
# 许可信息查看:言語成了邀功盡責的功臣,還需要行爲每日來值班嗎
# 描述: Algorithms
# Author : geovindu,Geovin Du 涂聚文.
# IDE : goLang 2024.3.6 go 26.2
# os : windows 10
# database : mysql 9.0 sql server 2019, postgreSQL 17.0 Oracle 21c Neo4j
# Datetime : 2026/7/8 23:07
# User : geovindu
# Product : GoLand
# Project : goalgorithms
# File : main.go
*/
/*
gui/main.go
TaskScheduler 定时任务调度器(Go版) - Windows GUI入口(基于lxn/walk原生控件)
功能:
- 任务列表表格(新增/编辑/删除/立即执行/查看历史/导入导出/刷新)
- 运行日志面板(与调度线程实时联动)
- 后台调度线程(cron触发、并发控制、一次性任务自动禁用)
- 系统托盘(可选,存在app.ico时启用)
编译:go build -o taskscheduler-gui.exe ./gui
对应 Python 版 main.py(tkinter/ttkbootstrap界面)
*/
package main
import (
"fmt"
"log"
"os"
"path/filepath"
"runtime/debug"
"strconv"
"strings"
"time"
"github.com/lxn/walk"
. "github.com/lxn/walk/declarative"
"taskscheduler/application"
"taskscheduler/config"
"taskscheduler/domain"
"taskscheduler/jsonio"
"taskscheduler/logger"
"taskscheduler/repository"
"taskscheduler/scheduler"
"taskscheduler/storage"
)
// MainWindowX 主窗口结构
type MainWindowX struct {
*walk.MainWindow // 内嵌主窗口
tv *walk.TableView // 任务表格视图
model *TaskTableModel // 任务表格模型
logEdit *walk.TextEdit // 日志文本框
ni *walk.NotifyIcon // 系统托盘图标
bgSched *scheduler.BackgroundScheduler // 后台调度线程
allowExit bool // 是否允许真正退出(托盘菜单置true)
dash *DashboardView // 仪表盘视图
dashStop chan struct{} // 仪表盘自动刷新停止信号
tabWidget *walk.TabWidget // 多标签页(任务列表/仪表盘)
}
// 全局服务实例(GUI版)
var (
taskRepo = repository.NewTaskRepo()
historyRepo = repository.NewTaskHistoryRepo()
appService = application.NewTaskSchedulerService()
jsonIO = jsonio.NewTaskJsonIO()
)
// ---------- 通用消息框 ----------
// showError 错误弹窗
func showError(owner walk.Form, title, msg string) {
walk.MsgBox(owner, title, msg, walk.MsgBoxIconError)
}
// showWarning 警告弹窗
func showWarning(owner walk.Form, title, msg string) {
walk.MsgBox(owner, title, msg, walk.MsgBoxIconWarning)
}
// showInfo 信息弹窗
func showInfo(owner walk.Form, title, msg string) {
walk.MsgBox(owner, title, msg, walk.MsgBoxIconInformation)
}
// confirm 确认弹窗,返回是否确认
func confirm(owner walk.Form, title, msg string) bool {
return walk.MsgBox(owner, title, msg, walk.MsgBoxIconQuestion|walk.MsgBoxYesNo) == walk.DlgCmdYes
}
// ---------- 主窗口方法 ----------
// selectedEntity 获取当前选中行对应的任务实体
func (mw *MainWindowX) selectedEntity() *domain.TaskEntity {
idx := mw.tv.CurrentIndex()
if idx < 0 || idx >= len(mw.model.Items()) {
return nil
}
return mw.model.Items()[idx]
}
// refreshTable 刷新任务表格数据
func (mw *MainWindowX) refreshTable() {
list, err := taskRepo.GetList(storage.DB)
if err != nil {
log.Printf("查询任务失败: %v", err)
return
}
mw.model.SetItems(list)
}
// showDashboard 切换到仪表盘标签页(工具栏"仪表盘"按钮)
func (mw *MainWindowX) showDashboard() {
if mw.tabWidget == nil {
return
}
// 切换到第2个标签页(索引1=仪表盘),并先刷新数据
if mw.dash != nil {
mw.dash.Refresh()
}
_ = mw.tabWidget.SetCurrentIndex(1)
}
// refreshAll 刷新任务表格 + 仪表盘
func (mw *MainWindowX) refreshAll() {
mw.refreshTable()
if mw.dash != nil {
mw.dash.Refresh()
}
}
// startDashAutoRefresh 启动仪表盘定时自动刷新(每5秒,退出时由dashStop停止)
func (mw *MainWindowX) startDashAutoRefresh() {
go func() {
ticker := time.NewTicker(5 * time.Second)
defer ticker.Stop()
for {
select {
case <-ticker.C:
// 窗口仍有效时刷新仪表盘(Synchronize调度到UI线程)
mw.Synchronize(func() {
if mw.dash != nil && mw.MainWindow != nil {
mw.dash.Refresh()
}
})
case <-mw.dashStop:
log.Println("仪表盘自动刷新已停止")
return
}
}
}()
log.Println("仪表盘自动刷新已启动(每5秒)")
}
// onAdd 新增任务
func (mw *MainWindowX) onAdd() {
if saved := showTaskEditDialog(mw, nil); saved != nil {
mw.refreshTable()
}
}
// onEdit 编辑任务
func (mw *MainWindowX) onEdit() {
entity := mw.selectedEntity()
if entity == nil {
showWarning(mw, "提示", "请先选中一行任务")
return
}
if saved := showTaskEditDialog(mw, entity); saved != nil {
mw.refreshTable()
}
}
// onDelete 删除任务
func (mw *MainWindowX) onDelete() {
entity := mw.selectedEntity()
if entity == nil {
showWarning(mw, "提示", "请先选中一行任务")
return
}
if !confirm(mw, "确认删除", "确定删除任务【"+entity.TaskName+"】?历史记录不会被删除") {
return
}
if err := taskRepo.DeleteByID(storage.DB, entity.TaskID); err != nil {
showError(mw, "删除失败", err.Error())
return
}
log.Printf("任务 %d【%s】已删除", entity.TaskID, entity.TaskName)
mw.refreshTable()
}
// onRunNow 手动立即执行任务(后台协程,不阻塞UI)
func (mw *MainWindowX) onRunNow() {
entity := mw.selectedEntity()
if entity == nil {
showWarning(mw, "提示", "请先选中一行任务")
return
}
go func() {
_, err := appService.ExecuteSingleTask(entity)
if err != nil {
log.Printf("任务执行失败 task_id=%d: %v", entity.TaskID, err)
return
}
// 一次性任务执行成功后自动禁用(与调度线程行为一致)
if len(entity.Remark) > 0 && strings.Contains(entity.Remark, "一次性") {
_ = taskRepo.UpdateEnable(storage.DB, entity.TaskID, false)
log.Printf("一次性任务 %d 执行完成,已自动禁用", entity.TaskID)
mw.Synchronize(func() { mw.refreshTable() })
}
}()
}
// onHistory 查看任务执行历史
func (mw *MainWindowX) onHistory() {
entity := mw.selectedEntity()
if entity == nil {
showWarning(mw, "提示", "请先选中一行任务")
return
}
showHistoryDialog(mw, entity.TaskID, entity.TaskName)
}
// onImport 从JSON导入任务
func (mw *MainWindowX) onImport() {
fd := walk.FileDialog{
Title: "选择JSON文件",
Filter: "JSON文件 (*.json)|*.json|全部文件 (*.*)|*.*",
}
ok, err := fd.ShowOpen(mw)
if err != nil || !ok {
return
}
stat, err := jsonIO.ImportFromFile(fd.FilePath)
if err != nil {
showError(mw, "导入失败", err.Error())
return
}
showInfo(mw, "导入完成",
"总条数="+itoa(stat.Total)+" 新增="+itoa(stat.Insert)+" 更新="+itoa(stat.Update)+" 跳过="+itoa(stat.Skip))
mw.refreshTable()
}
// onExport 导出全部任务到JSON
func (mw *MainWindowX) onExport() {
fd := walk.FileDialog{
Title: "导出任务到JSON",
Filter: "JSON文件 (*.json)|*.json|全部文件 (*.*)|*.*",
}
ok, err := fd.ShowSave(mw)
if err != nil || !ok {
return
}
if err := jsonIO.ExportAll(fd.FilePath); err != nil {
showError(mw, "导出失败", err.Error())
return
}
showInfo(mw, "导出完成", "已导出到:"+fd.FilePath)
}
// setupTray 设置系统托盘(app.ico存在时启用)
// 图标路径按exe所在目录定位,保证从任意位置启动都能找到
func (mw *MainWindowX) setupTray() {
// 定位 app.ico:优先exe所在目录,找不到则回退当前工作目录
iconPath := "app.ico"
if exePath, err := os.Executable(); err == nil {
iconPath = filepath.Join(filepath.Dir(exePath), "app.ico")
if _, statErr := os.Stat(iconPath); statErr != nil {
iconPath = "app.ico" // exe目录没有则回退工作目录
}
}
icon, err := walk.NewIconFromFile(iconPath)
if err != nil {
log.Printf("未找到app.ico,跳过系统托盘: %v", err)
return
}
ni, err := walk.NewNotifyIcon(mw)
if err != nil {
log.Printf("创建托盘图标失败: %v", err)
return
}
mw.ni = ni
_ = ni.SetIcon(icon)
_ = ni.SetToolTip(config.Cfg.App.AppName)
menu := ni.ContextMenu()
// 菜单:显示窗口
showAction := walk.NewAction()
_ = showAction.SetText("显示窗口")
showAction.Triggered().Attach(func() { mw.Show() })
menu.Actions().Add(showAction)
// 菜单:退出程序(置allowExit后放行关闭)
quitAction := walk.NewAction()
_ = quitAction.SetText("退出程序")
quitAction.Triggered().Attach(func() {
mw.allowExit = true
mw.Close()
})
menu.Actions().Add(quitAction)
// 左键双击显示窗口
ni.MouseDown().Attach(func(x, y int, button walk.MouseButton) {
if button == walk.LeftButton {
mw.Show()
}
})
_ = ni.SetVisible(true)
log.Println("系统托盘已启用")
}
// initCloseBehavior 关闭窗口→最小化到托盘(托盘启用时生效)
// 拦截窗口关闭事件:点右上角X只隐藏窗口,真正退出走托盘菜单
func (mw *MainWindowX) initCloseBehavior() {
if mw.ni == nil {
log.Println("托盘未启用,关闭窗口将直接退出程序")
return
}
// 挂接Closing事件(在UI线程回调)
mw.Closing().Attach(func(canceled *bool, reason walk.CloseReason) {
// 托盘"退出程序"菜单已置allowExit:放行真正关闭
if mw.allowExit {
return
}
// 否则取消关闭,隐藏窗口到托盘
*canceled = true
mw.Hide()
_ = mw.ni.ShowMessage("TaskScheduler", "已最小化到托盘,双击托盘图标可重新打开窗口")
})
log.Println("已启用:关闭窗口最小化到托盘(退出请用托盘菜单)")
}
// itoa 整数转字符串(简化strconv使用)
func itoa(n int) string {
return strconv.Itoa(n)
}
func main() {
// ---------- 0. 全局异常捕获(panic时写诊断文件,避免静默退出) ----------
defer func() {
if r := recover(); r != nil {
diagFile := "logs/gui_panic.log"
_ = os.MkdirAll("logs", os.ModePerm)
msg := fmt.Sprintf("GUI发生致命异常: %v\n%s\n", r, debug.Stack())
_ = os.WriteFile(diagFile, []byte(msg), 0644)
log.Printf("GUI发生致命异常,已写入%s: %v", diagFile, r)
// 弹出错误对话框(窗口若已存在则附着)
walk.MsgBox(nil, "程序异常", "发生未捕获异常:"+fmt.Sprint(r), walk.MsgBoxIconError)
}
}()
// ---------- 1. 加载配置 ----------
cfg, err := config.Load("config/app.yaml")
if err != nil {
log.Printf("配置加载失败: %v", err)
return
}
// ---------- 2. 初始化数据库 ----------
if err := storage.InitDB(cfg.Database.DBFile); err != nil {
log.Printf("数据库初始化失败: %v", err)
return
}
defer storage.Close()
// ---------- 3. 构建主窗口 ----------
mw := &MainWindowX{}
mw.model = NewTaskTableModel()
mw.dash = newDashboardView()
mw.dashStop = make(chan struct{})
// 日志初始化:控制台 + 滚动文件 + GUI文本框
logger.InitWithWriter(cfg.App.LogDir, NewGuiLogWriter(mw))
// ---------- 4. 启动后台调度线程 ----------
mw.bgSched = scheduler.NewBackgroundScheduler()
go mw.bgSched.Run()
log.Printf("后台调度线程已启动")
// ---------- 5. 创建主窗口(Create与Run分离:窗口创建后、消息循环前初始化托盘) ----------
err = (MainWindow{
AssignTo: &mw.MainWindow,
Title: cfg.App.AppName,
MinSize: Size{Width: 1000, Height: 700},
Layout: VBox{},
Children: []Widget{
// 工具栏
Composite{
Layout: HBox{},
Children: []Widget{
PushButton{Text: "新增", OnClicked: mw.onAdd},
PushButton{Text: "编辑", OnClicked: mw.onEdit},
PushButton{Text: "删除", OnClicked: mw.onDelete},
PushButton{Text: "立即执行", OnClicked: mw.onRunNow},
PushButton{Text: "查看历史", OnClicked: mw.onHistory},
PushButton{Text: "仪表盘", OnClicked: mw.showDashboard},
VSpacer{},
PushButton{Text: "导入JSON", OnClicked: mw.onImport},
PushButton{Text: "导出JSON", OnClicked: mw.onExport},
PushButton{Text: "刷新", OnClicked: mw.refreshAll},
},
},
// 多标签页:任务列表 + 仪表盘
TabWidget{
AssignTo: &mw.tabWidget,
Pages: []TabPage{
{
Title: "任务列表",
Layout: VBox{},
Children: []Widget{
TableView{
AssignTo: &mw.tv,
Model: mw.model,
Columns: []TableViewColumn{
{Title: "ID", Width: 60},
{Title: "任务名称", Width: 160},
{Title: "Cron表达式", Width: 140},
{Title: "启用", Width: 60},
{Title: "状态", Width: 80},
{Title: "备注", Width: 300},
},
OnItemActivated: func() { mw.onHistory() },
},
},
},
mw.dash.page(),
},
},
Label{Text: "运行日志:"},
// 日志面板
TextEdit{AssignTo: &mw.logEdit, ReadOnly: true, MinSize: Size{Height: 160}},
},
}).Create()
if err != nil {
log.Printf("窗口创建失败: %v", err)
return
}
// ---------- 6. 窗口已创建(UI线程、句柄有效):初始化托盘+关闭行为+刷新数据+仪表盘自动刷新 ----------
mw.setupTray()
mw.initCloseBehavior()
mw.refreshAll()
mw.startDashAutoRefresh()
mw.Show()
// ---------- 7. 进入消息循环(阻塞直到窗口关闭) ----------
code := mw.Run()
log.Printf("窗口消息循环退出 code=%d", code)
if err != nil {
log.Printf("GUI启动失败: %v", err)
}
// ---------- 8. 窗口关闭后清理 ----------
if mw.bgSched != nil {
mw.bgSched.Stop()
}
close(mw.dashStop) // 停止仪表盘自动刷新goroutine
if mw.ni != nil {
_ = mw.ni.Dispose()
}
log.Println("程序退出")
}
Go
/*
# 版权所有 2026 ©涂聚文有限公司™ ®
# 许可信息查看:言語成了邀功盡責的功臣,還需要行爲每日來值班嗎
# 描述: Algorithms
# Author : geovindu,Geovin Du 涂聚文.
# IDE : goLang 2024.3.6 go 26.2
# os : windows 10
# database : mysql 9.0 sql server 2019, postgreSQL 17.0 Oracle 21c Neo4j
# Datetime : 2026/7/8 23:07
# User : geovindu
# Product : GoLand
# Project : goalgorithms
# File : main.go
TaskScheduler 定时任务调度器(Go版) - 程序入口
CLI命令:
run 启动调度守护线程(阻塞运行)
list 列出全部任务
add 新增任务(参数见下方示例)
del <id> 删除任务
enable <id> <1|0> 启用/禁用任务
runonce <id> 手动立即执行一次任务
history <id> 查看任务执行历史
import <file> 从JSON导入任务
export <file> 导出全部任务到JSON
*/
package main
import (
"fmt"
"os"
"os/signal"
"strconv"
"strings"
"time"
"taskscheduler/application"
"taskscheduler/config"
"taskscheduler/domain"
"taskscheduler/jsonio"
"taskscheduler/logger"
"taskscheduler/repository"
"taskscheduler/scheduler"
"taskscheduler/storage"
)
// printUsage 打印命令帮助
func printUsage() {
fmt.Println("TaskScheduler 定时任务调度器(Go版)")
fmt.Println("用法: taskscheduler <命令> [参数]")
fmt.Println("")
fmt.Println(" 任务管理:")
fmt.Println(" list 列出全部任务")
fmt.Println(" add -name 名称 -cron 表达式 -command 命令 [-timeout 300] [-retry 0] [-gap 10] [-disable] [-remark 备注]")
fmt.Println(" del <任务ID> 删除任务")
fmt.Println(" enable <任务ID> <1|0> 启用/禁用任务")
fmt.Println(" 执行与调度:")
fmt.Println(" run 启动调度守护线程(阻塞,Ctrl+C退出)")
fmt.Println(" runonce <任务ID> 手动立即执行一次任务")
fmt.Println(" 导入导出:")
fmt.Println(" import <JSON文件> 从JSON导入任务")
fmt.Println(" export <JSON文件> 导出全部任务到JSON")
fmt.Println(" 历史记录:")
fmt.Println(" history <任务ID> [条数] 查看任务执行历史")
fmt.Println("")
fmt.Println("示例:")
fmt.Println(" taskscheduler add -name 心跳检测 -cron \"*/5 * * * *\" -command \"echo ok\"")
fmt.Println(" taskscheduler run")
fmt.Println(" taskscheduler runonce 1")
fmt.Println(" taskscheduler import examples/task_examples.json")
}
// 全局服务实例
var (
taskRepo = repository.NewTaskRepo()
historyRepo = repository.NewTaskHistoryRepo()
appService = application.NewTaskSchedulerService()
jsonIO = jsonio.NewTaskJsonIO()
)
// cmdList 列出全部任务
func cmdList() {
list, err := taskRepo.GetList(storage.DB)
if err != nil {
fmt.Printf("查询任务失败: %v\n", err)
os.Exit(1)
}
if len(list) == 0 {
fmt.Println("(暂无任务,可使用 add 或 import 添加)")
return
}
fmt.Printf("%-5s %-20s %-18s %-8s %-8s %s\n", "ID", "任务名称", "Cron表达式", "状态", "启用", "备注")
fmt.Println(strings.Repeat("-", 90))
for _, t := range list {
enableText := "是"
if !t.IsEnable {
enableText = "否"
}
remark := t.Remark
if len(remark) > 20 {
remark = remark[:20] + "..."
}
fmt.Printf("%-5d %-20s %-18s %-8s %-8s %s\n",
t.TaskID, t.TaskName, t.CronExpr, t.TaskStatus.String(), enableText, remark)
}
}
// cmdAdd 新增任务
func cmdAdd(args []string) {
entity := &domain.TaskEntity{
TimeoutSec: 300,
MaxRetry: 0,
RetryGapSec: 10,
IsEnable: true,
TaskStatus: domain.TaskStatusIDLE,
}
disable := false
for i := 0; i < len(args); i++ {
switch args[i] {
case "-name":
if i+1 < len(args) {
entity.TaskName = args[i+1]
i++
}
case "-cron":
if i+1 < len(args) {
entity.CronExpr = args[i+1]
i++
}
case "-command":
if i+1 < len(args) {
entity.Command = args[i+1]
i++
}
case "-timeout":
if i+1 < len(args) {
entity.TimeoutSec, _ = strconv.Atoi(args[i+1])
i++
}
case "-retry":
if i+1 < len(args) {
entity.MaxRetry, _ = strconv.Atoi(args[i+1])
i++
}
case "-gap":
if i+1 < len(args) {
entity.RetryGapSec, _ = strconv.Atoi(args[i+1])
i++
}
case "-remark":
if i+1 < len(args) {
entity.Remark = args[i+1]
i++
}
case "-disable":
disable = true
}
}
if entity.TaskName == "" || entity.CronExpr == "" || entity.Command == "" {
fmt.Println("错误:-name / -cron / -command 为必填参数")
os.Exit(1)
}
entity.IsEnable = !disable
if _, err := taskRepo.Save(storage.DB, entity); err != nil {
fmt.Printf("新增任务失败: %v\n", err)
os.Exit(1)
}
fmt.Printf("新增成功:ID=%d 名称=%s cron=%s\n", entity.TaskID, entity.TaskName, entity.CronExpr)
}
// cmdDelete 删除任务
func cmdDelete(idStr string) {
id, err := strconv.ParseInt(idStr, 10, 64)
if err != nil {
fmt.Println("错误:任务ID必须为数字")
os.Exit(1)
}
if err := taskRepo.DeleteByID(storage.DB, id); err != nil {
fmt.Printf("删除任务失败: %v\n", err)
os.Exit(1)
}
fmt.Printf("任务 %d 已删除\n", id)
}
// cmdEnable 启用/禁用任务
func cmdEnable(idStr, enableStr string) {
id, err := strconv.ParseInt(idStr, 10, 64)
if err != nil {
fmt.Println("错误:任务ID必须为数字")
os.Exit(1)
}
enable := enableStr == "1"
if err := taskRepo.UpdateEnable(storage.DB, id, enable); err != nil {
fmt.Printf("更新任务状态失败: %v\n", err)
os.Exit(1)
}
statusText := "已启用"
if !enable {
statusText = "已禁用"
}
fmt.Printf("任务 %d %s\n", id, statusText)
}
// cmdRunOnce 手动立即执行一次任务
func cmdRunOnce(idStr string) {
id, err := strconv.ParseInt(idStr, 10, 64)
if err != nil {
fmt.Println("错误:任务ID必须为数字")
os.Exit(1)
}
task, err := taskRepo.GetByID(storage.DB, id)
if err != nil {
fmt.Printf("查询任务失败: %v\n", err)
os.Exit(1)
}
if task == nil {
fmt.Printf("任务 %d 不存在\n", id)
os.Exit(1)
}
start := time.Now()
history, err := appService.ExecuteSingleTask(task)
if err != nil {
fmt.Printf("执行失败: %v\n", err)
os.Exit(1)
}
elapsed := time.Since(start).Round(time.Millisecond)
resultText := "成功"
if !history.IsSuccess {
resultText = "失败"
}
fmt.Printf("任务 %d【%s】执行%s,耗时%v,历史ID=%d\n",
id, task.TaskName, resultText, elapsed, history.HistoryID)
// 一次性任务执行成功后自动禁用(与调度线程行为一致)
if len(task.Remark) > 0 && strings.Contains(task.Remark, "一次性") {
_ = taskRepo.UpdateEnable(storage.DB, id, false)
fmt.Printf("一次性任务 %d 已自动禁用\n", id)
}
}
// cmdHistory 查看任务执行历史
func cmdHistory(idStr, limitStr string) {
id, err := strconv.ParseInt(idStr, 10, 64)
if err != nil {
fmt.Println("错误:任务ID必须为数字")
os.Exit(1)
}
limit := 20
if limitStr != "" {
limit, _ = strconv.Atoi(limitStr)
}
histories, err := historyRepo.ListByTaskID(storage.DB, id, limit)
if err != nil {
fmt.Printf("查询历史失败: %v\n", err)
os.Exit(1)
}
if len(histories) == 0 {
fmt.Printf("任务 %d 暂无执行历史\n", id)
return
}
fmt.Printf("%-6s %-20s %-20s %-6s %s\n", "历史ID", "开始时间", "结束时间", "结果", "输出摘要")
fmt.Println(strings.Repeat("-", 100))
for _, h := range histories {
resultText := "成功"
if !h.IsSuccess {
resultText = "失败"
}
msg := h.ExecuteMsg
msg = strings.ReplaceAll(msg, "\n", " ")
if len(msg) > 40 {
msg = msg[:40] + "..."
}
fmt.Printf("%-6d %-20s %-20s %-6s %s\n",
h.HistoryID,
h.ExecuteStartTime.Format("2006-01-02 15:04:05"),
h.ExecuteEndTime.Format("2006-01-02 15:04:05"),
resultText, msg)
}
}
// cmdRun 启动调度守护线程(阻塞运行,Ctrl+C 优雅退出)
func cmdRun() {
bg := scheduler.NewBackgroundScheduler()
go bg.Run()
fmt.Println("调度守护已启动,按 Ctrl+C 退出...")
sigCh := make(chan os.Signal, 1)
signal.Notify(sigCh, os.Interrupt)
<-sigCh
fmt.Println("收到退出信号,正在停止调度线程...")
bg.Stop()
time.Sleep(300 * time.Millisecond)
fmt.Println("调度线程已停止,程序退出")
}
func main() {
// ---------- 1. 加载配置 ----------
cfg, err := config.Load("config/app.yaml")
if err != nil {
fmt.Printf("配置加载失败: %v\n", err)
os.Exit(1)
}
// ---------- 2. 初始化日志 ----------
logger.Init(cfg.App.LogDir)
// ---------- 3. 初始化数据库 ----------
if err := storage.InitDB(cfg.Database.DBFile); err != nil {
fmt.Printf("数据库初始化失败: %v\n", err)
os.Exit(1)
}
defer storage.Close()
// ---------- 4. 解析命令 ----------
if len(os.Args) < 2 {
printUsage()
return
}
cmd := os.Args[1]
args := os.Args[2:]
switch cmd {
case "list":
cmdList()
case "add":
cmdAdd(args)
case "del":
if len(args) < 1 {
fmt.Println("用法: taskscheduler del <任务ID>")
return
}
cmdDelete(args[0])
case "enable":
if len(args) < 2 {
fmt.Println("用法: taskscheduler enable <任务ID> <1|0>")
return
}
cmdEnable(args[0], args[1])
case "run":
cmdRun()
case "runonce":
if len(args) < 1 {
fmt.Println("用法: taskscheduler runonce <任务ID>")
return
}
cmdRunOnce(args[0])
case "history":
limit := ""
if len(args) >= 2 {
limit = args[1]
}
if len(args) < 1 {
fmt.Println("用法: taskscheduler history <任务ID> [条数]")
return
}
cmdHistory(args[0], limit)
case "import":
if len(args) < 1 {
fmt.Println("用法: taskscheduler import <JSON文件>")
return
}
stat, err := jsonIO.ImportFromFile(args[0])
if err != nil {
fmt.Printf("导入失败: %v\n", err)
os.Exit(1)
}
fmt.Printf("导入完成:总条数=%d 新增=%d 更新=%d 跳过=%d\n",
stat.Total, stat.Insert, stat.Update, stat.Skip)
case "export":
if len(args) < 1 {
fmt.Println("用法: taskscheduler export <JSON文件>")
return
}
if err := jsonIO.ExportAll(args[0]); err != nil {
fmt.Printf("导出失败: %v\n", err)
os.Exit(1)
}
fmt.Printf("导出完成:%s\n", args[0])
default:
fmt.Printf("未知命令: %s\n\n", cmd)
printUsage()
}
}
输出:

