go: Task Scheduler

一、引言

定时任务调度是后端系统中最常见也最容易出错的场景之一。本文以 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 &lt;- struct{}{}
    go s.worker(t, &amp;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 &lt;-s.stopCh:
        log.Println("调度主线程已退出")
        return
    case now := &lt;-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 &gt; 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 := &amp;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()
    }
}

输出:

相关推荐
张三博客1 小时前
Java 开发实用工具类合集:从日期处理到并发控制
后端
用户970161501681 小时前
微服务里最危险的 DELETE,不是删不掉,是只删了一半
人工智能·后端
Zane19941 小时前
线上突然OOM,你的排查顺序是先看日志还是先重启
java·后端
西门老铁1 小时前
UUID 还是雪花 ID?分布式唯一 ID 方案怎么选?
后端
吃饱了得干活1 小时前
Java设计模式实战:一个支付模块的重构之旅,层层递进理解设计模式精髓
后端·设计模式·架构
会飞的拖把1 小时前
Python 多任务编程:并发、进程、线程与线程安全详解
开发语言·python
君顾12 小时前
折扣卡 CPS 小程序定制:从业务流程到系统架构的实践指南
java·开发语言·折扣卡
江湖十年2 小时前
Go 还是 Golang?可能你一直都搞错了!
后端·面试·go