签入版本

This commit is contained in:
小蚂蚁云
2026-10-20 19:08:49 +08:00
commit 1f248e89e4
890 changed files with 119227 additions and 0 deletions
+277
View File
@@ -0,0 +1,277 @@
// +======================================================================
// | EasyGoAdmin 企业数字化开发平台 [ 赋能开发者,助力企业发展 ]
// +----------------------------------------------------------------------
// | Copyright (c) 2019-2026 半城风雨团队 版权所有
// +----------------------------------------------------------------------
// | 开源协议: Apache-2.0
// | 重要声明: 未经书面许可,不得移除或修改本框架相关版权标识。
// +----------------------------------------------------------------------
// | 官方网站: https://www.easygoadmin.vip
// +----------------------------------------------------------------------
// | 研发团队: 小蚂蚁云团队 荣誉出品
// +======================================================================
// | 版权和免责声明:
// | 一、知识产权声明
// | 本团队对该软件框架产品(以下简称“本产品”)拥有完整知识产权,包括但不限于商标权、专利权、
// | 著作权、商业秘密等。前述权利均受中华人民共和国及相关国际法律法规保护。未经本团队书面授权,
// | 任何个人或单位不得基于本产品本身申请任何形式的知识产权。
// | 二、使用限制与禁止条款
// | 您理解并同意,禁止将本产品用于以下任何行为:
// | 1. 任何违反中华人民共和国法律法规的用途;
// | 2. 任何侵害他人合法权益(包括但不限于人身权、财产权、知识产权等)的行为;
// | 3. 任何危害国家安全、公共利益及社会道德风尚的行为。
// | 三、责任豁免
// | 因使用本产品而产生的任何损失或法律纠纷(包括但不限于直接损失、间接损失、附带损失、衍生损失
// | 以及因违约、侵权等引发的责任),本团队均不承担任何法律责任。若因违规使用本产品导致任何第三
// | 方提起索赔或行政机关进行调查,您应独立承担全部责任。
// | 四、违约与法律责任
// | 如发现任何违反本声明的行为,本团队有权:
// | 1. 无条件配合公安机关及其他有权机关的调查取证工作;
// | 2. 依法追究相关方的民事责任,并要求赔偿全部损失;
// | 3. 保留进一步采取刑事控告等法律措施的权利。
// | 五、许可范围说明
// | 1、被授权方应视本产品源代码为核心机密信息,负有最高标准的保密与保管义务。禁止以任何形式(包
// | 括但不限于直接或间接的分享、复制、传播、分发、转售、出租、开源、反向工程、反编译、反汇编或通
// | 过网络服务提供给任何第三方)泄露、披露或处置源代码的全部或部分内容。任何未经授权的复制、使用、
// | 传播行为均构成对本团队知识产权的严重侵权,本团队有权依法追究其全部法律责任,并要求赔偿因此造
// | 成的一切经济损失及商誉损害。
// | 2、本产品仅限于公司或个人在法律允许范围内,用于内部且合规的软件产品研发。具体权利义务以《软件
// | 许可协议》附件为准。
// +======================================================================
package logic
import (
"context"
"fmt"
"strings"
"sync"
"time"
"github.com/gogf/gf/v2/frame/g"
"github.com/gogf/gf/v2/net/gclient"
"github.com/gogf/gf/v2/os/gcron"
"easygoadmin/internal/dao"
"easygoadmin/utility/cache"
)
// ============================================================================
// 定时任务调度器:基于 gcron 的常驻调度,启动时加载 status=1 的任务,
// 按 cron_expression 触发 url+params(HTTP 调用本服务接口),结果写任务日志表。
//
// 任务键:job:{id}(gcron 命名条目),暂停=Remove、恢复=重新 Add。
// ============================================================================
var (
daemonOnce sync.Once // 保证调度器仅初始化一次
daemonStart time.Time // 调度器启动时间
)
// jobLockTTLSeconds 任务执行锁 TTL(秒)
// 兜底上限:执行实例崩溃未主动释放时锁自动过期;正常路径执行结束即释放
const jobLockTTLSeconds = 300
// jobTryLock 抢占任务执行锁
//
// 键 job:lock:{id} 经 utility/cache(Redis 优先、内存降级)SET NX 实现,
// 多实例部署共享同一把锁,防止同一任务被并发执行;
// 抢占成功返回释放函数,失败返回 nil(调用方放弃本次执行)
func jobTryLock(jobId int64) func() {
key := fmt.Sprintf("job:lock:%d", jobId)
ctx := context.Background()
if !cache.SetNX(ctx, key, "1", jobLockTTLSeconds) {
return nil
}
return func() { _ = cache.Del(ctx, key) }
}
// jobKey 生成 gcron 条目键
func jobKey(jobId int64) string {
return fmt.Sprintf("job:%d", jobId)
}
// DaemonInit 启动时加载运行中的任务
// 幂等,多次调用仅首次生效
func DaemonInit(ctx context.Context) {
daemonOnce.Do(func() {
daemonStart = time.Now()
rows, err := dao.Job.Ctx(ctx).Where("status", 1).All()
if err != nil {
g.Log().Warningf(ctx, "定时任务加载失败: %v", err)
return
}
g.Log().Debugf(ctx, "定时任务待挂载: %d 条", len(rows))
loaded := 0
for _, r := range rows {
if err := addJobToScheduler(r["id"].Int64(), r["cron_expression"].String(),
r["execute_policy"].Int(), r["is_sync"].Int()); err != nil {
g.Log().Warningf(ctx, "任务挂载失败 job=%d cron=%q: %v",
r["id"].Int64(), r["cron_expression"].String(), err)
continue
}
loaded++
}
g.Log().Infof(ctx, "定时任务调度器已启动,加载 %d 个任务", loaded)
})
}
// addJobToScheduler 挂载任务到调度器
//
// 执行策略(execute_policy):
// - 1 立即执行:允许并发,tick 即启动新执行(gcron.Add)
// - 2 执行一次(默认):单例调度,上次未结束则跳过本次(gcron.AddSingleton)
// - 3 放弃执行:并发 tick 照常触发,但抢不到执行锁则放弃本次
//
// 执行方式(is_sync):1 同步在调度协程内执行;0 异步 go 协程执行。
// 策略 2/3 均叠加执行锁(多实例共享),策略 1 按并发语义不加锁。
func addJobToScheduler(jobId int64, cronExpr string, executePolicy int, isSync int) error {
if cronExpr == "" {
return fmt.Errorf("cron表达式为空")
}
handler := func(c context.Context) {
run := func(ctx context.Context) {
if executePolicy != 1 {
unlock := jobTryLock(jobId)
if unlock == nil {
g.Log().Debugf(ctx, "任务 %d 存在执行中的实例,本次放弃", jobId)
return
}
defer unlock()
}
executeJob(ctx, jobId, false)
}
if isSync == 1 {
run(c)
} else {
go run(context.Background())
}
}
var err error
if executePolicy == 1 {
_, err = gcron.Add(context.Background(), cronExpr, handler, jobKey(jobId))
} else {
_, err = gcron.AddSingleton(context.Background(), cronExpr, handler, jobKey(jobId))
}
return err
}
// removeJobFromScheduler 从调度器摘除任务
func removeJobFromScheduler(jobId int64) {
gcron.Remove(jobKey(jobId))
}
// hasJobInScheduler 判断调度器中是否包含指定任务
func hasJobInScheduler(jobId int64) bool {
return gcron.Search(jobKey(jobId)) != nil
}
// executeJob 执行任务
//
// 流程:读取任务定义 → HTTP 调用目标地址 → 写入执行日志;
// 内部捕获 panic,避免异常外泄影响调度器。
// force 为 true 时跳过状态检查,用于手动"立即执行"场景,未发布任务也可执行。
func executeJob(ctx context.Context, jobId int64, force bool) {
defer func() {
if e := recover(); e != nil {
g.Log().Errorf(ctx, "定时任务 %d 执行 panic: %v", jobId, e)
}
}()
one, err := dao.Job.Ctx(ctx).Where("id", jobId).One()
if err != nil || len(one) == 0 {
removeJobFromScheduler(jobId)
return
}
if !force && one["status"].Int() != 1 {
removeJobFromScheduler(jobId)
return
}
start := time.Now()
status := 0
message := "执行成功"
exception := ""
// 任务执行目标分派:
// http(s) 地址 → HTTP 调用;其余取值 → 按方法名查找本地处理方法执行
target := one["url"].String()
if target == "" {
status = 1
message = "执行失败"
exception = "任务URL为空"
} else if strings.HasPrefix(target, "http://") || strings.HasPrefix(target, "https://") {
client := gclient.New()
client.SetContentType("application/json")
client.SetTimeout(30 * time.Second)
body := one["params"].String()
var response *gclient.Response
var rerr error
if body == "" {
response, rerr = client.Get(ctx, target)
} else {
response, rerr = client.Post(ctx, target, body)
}
if rerr != nil {
status = 1
message = "执行失败"
exception = truncStr(rerr.Error(), 1000)
} else {
respBody := truncStr(response.ReadAllString(), 1000)
if response.StatusCode != 200 {
status = 1
message = "执行失败"
exception = fmt.Sprintf("HTTP %d: %s", response.StatusCode, respBody)
} else {
message = truncStr("执行成功: "+respBody, 500)
}
}
} else {
handler, ok := jobHandlers[target]
if !ok {
status = 1
message = "执行失败"
exception = fmt.Sprintf("未找到本地任务处理方法: %s", truncStr(target, 200))
} else {
summary, herr := handler(ctx, one["params"].String())
if herr != nil {
status = 1
message = "执行失败"
exception = truncStr(herr.Error(), 1000)
} else if summary != "" {
message = truncStr("执行成功: "+summary, 500)
}
}
}
end := time.Now()
// 执行日志落库(后台 ctx,独立于任务 ctx)
if _, werr := dao.JobLog.Ctx(ctx).Data(map[string]any{
"job_id": jobId,
"job_name": one["job_name"].String(),
"job_group": one["job_group"].String(),
"job_trigger": one["job_trigger"].String(),
"job_message": message,
"cron_expression": one["cron_expression"].String(),
"status": status,
"start_time": start,
"end_time": end,
"consume_time": end.Sub(start).Milliseconds(),
"exception_info": exception,
"create_user": "daemon",
}).Insert(); werr != nil {
g.Log().Errorf(ctx, "任务日志写入失败 job=%d: %v", jobId, werr)
}
}
// truncStr 按字符数截断字符串
// 长度未超限时原样返回,超限时按 rune 截断,避免截断多字节字符
func truncStr(s string, maxLen int) string {
if len(s) <= maxLen {
return s
}
runes := []rune(s)
if len(runes) <= maxLen {
return s
}
return string(runes[:maxLen])
}