签入版本

This commit is contained in:
2026-09-12 18:57:19 +08:00
parent d45b61d808
commit 2857e0f2da
295 changed files with 20713 additions and 13125 deletions
+186 -33
View File
@@ -1,14 +1,14 @@
# +======================================================================
# | DjangoAdmin 企业数字化开发平台 [ 赋能开发者,助力企业发展 ]
# +----------------------------------------------------------------------
# | Copyright (c) 2021-2026 一米阳光团队 版权所有
# | Copyright (c) 2021-2026 小蚂蚁云团队 版权所有
# +----------------------------------------------------------------------
# | 开源协议: Apache-2.0
# | 重要声明: 未经书面许可,不得移除或修改本框架相关版权标识。
# +----------------------------------------------------------------------
# | 官方网站: https://www.djangoadmin.cn
# +----------------------------------------------------------------------
# | 研发团队: 一米阳光团队 荣誉出品
# | 研发团队: 小蚂蚁云团队 荣誉出品
# +======================================================================
# | 版权和免责声明:
# | 一、知识产权声明
@@ -50,6 +50,7 @@
import logging
import sys
import threading
import uuid
from datetime import datetime
from typing import Dict, Any, Optional
@@ -60,6 +61,7 @@ from apscheduler.jobstores.base import JobLookupError
from config import INTERNAL_API_KEY, FLASK_SITE_DOMAIN
from src.common.utils.model_utils import model_to_dict
from src.core.redis_client import redis
# ============================================================
# 全局变量和日志配置
@@ -72,6 +74,10 @@ logger = logging.getLogger(__name__)
# 调度器实例(延迟初始化)
_scheduler = None
# 调度线程内可用的 Flask app(init_scheduler 在 app 上下文内捕获,供写任务日志用)。
# APScheduler 的 cron 回调运行在后台线程,没有请求上下文,写库前需手动推 app 上下文。
_SCHEDULER_APP = None
# ============================================================
# 调度器实例获取函数
@@ -98,6 +104,46 @@ def get_scheduler():
# 创建全局调度器实例
scheduler = get_scheduler()
# ============================================================
# 分布式任务锁(防重复执行)
# ============================================================
#: 锁最长持有时间(秒)。作为进程崩溃时的兜底,超时自动释放,
#: 避免长期占用导致任务被永久跳过。
_JOB_LOCK_TTL_SECONDS = 300
class _JobExecutionLock:
"""
基于 Redis 的分布式任务锁。
背景:调度器是进程内的 BackgroundScheduler,gunicorn 多 worker / 多实例
部署时每个进程都会注册并触发同一 cron 任务。通过 Redis SET NX EX 原子操作
保证同一 job_id 在同一触发周期内只有一个实例真正执行。
acquire() 失败(Redis 不可用)时降级放行,避免锁服务故障导致任务停摆。
"""
def __init__(self, job_id: int):
self.key = f"scheduler:job_lock:{job_id}"
self.token = uuid.uuid4().hex
def acquire(self) -> bool:
try:
# 原子 SET key token NX EX ttl:并发下仅一个实例能成功
return bool(redis.client.set(self.key, self.token, nx=True, ex=_JOB_LOCK_TTL_SECONDS))
except Exception as e:
logger.error(f"获取调度任务锁异常 [ID: {self.key}]: {str(e)}")
return True
def release(self):
try:
# 仅删除自己持有的锁,避免误删其他实例刚获取的新锁
if redis.client.get(self.key) == self.token:
redis.client.delete(self.key)
except Exception as e:
logger.error(f"释放调度任务锁异常 [ID: {self.key}]: {str(e)}")
# ============================================================
# URL 构建工具函数
@@ -140,6 +186,68 @@ def build_full_url(url: str) -> str:
return url
# ============================================================
# 任务执行日志(每次实际执行后写入 flask_job_log)
# ============================================================
def _extract_log_fields(result) -> tuple:
"""从执行结果提取 (success, message, error) 三元组,兼容 URL/触发器两种返回形态。
- URL 任务:result = {"success": bool, "status_code":..., "data":...}
- 触发器任务:result = {"success": True, "data": {"success": bool, "message": ...}}
(真正成败在 data 里,由 executor 返回)
"""
if not isinstance(result, dict):
return True, str(result), None
success = result.get("success")
data = result.get("data")
if isinstance(data, dict) and "success" in data:
success = data.get("success")
message = result.get("message") or (data.get("message") if isinstance(data, dict) else None) or ""
error = result.get("error")
if not message and data is not None:
message = str(data)
return bool(success), str(message or ""), (str(error) if error else None)
def _write_job_log(job_id, success, message, error, start, end):
"""在 app 上下文内写任务日志(尽力而为,写失败不影响任务执行结果)。"""
if _SCHEDULER_APP is None:
logger.error("调度器 app 未绑定,跳过任务日志写入 [ID: %s]", job_id)
return
try:
with _SCHEDULER_APP.app_context():
from src.modules.job_log.service import write_job_log
write_job_log(
job_id, success=success, message=message, error=error,
start_time=start, end_time=end,
)
except Exception as e:
logger.error(f"写入任务日志异常 [ID: {job_id}]: {e}")
def _execute_and_log(job_id, func, *args, **kwargs):
"""执行任务并自动记录执行日志(开始/结束时间、耗时、结果、异常)。
无论成功失败(含异常)都会写一条 flask_job_log;返回执行结果给调用方。
"""
start = datetime.now()
try:
result = func(*args, **kwargs)
success, message, error = _extract_log_fields(result)
except Exception as e:
result = {"success": False, "error": str(e)}
success, message, error = False, str(e), str(e)
logger.error(f"执行任务异常 [ID: {job_id}]: {e}")
end = datetime.now()
_write_job_log(job_id, success, message, error, start, end)
elapsed = (end - start).total_seconds()
status = "成功" if success else "失败"
print(f"[JobScheduler] 任务 [ID: {job_id}] 执行{status},耗时 {elapsed:.2f}s,结果: {message or error}")
return result
# ============================================================
# 任务执行核心函数
# ============================================================
@@ -167,28 +275,45 @@ def execute_job(
Returns:
Dict: 执行结果,包含 success 和 data/error 字段
"""
logger.debug(f"execute_job id={job_id} url={url} sync={is_sync} trigger={job_trigger} params={params}")
sys.stdout.flush()
logger.info(f"执行定时任务 [ID: {job_id}, URL: {url}, Trigger: {job_trigger}]")
print(f"[JobScheduler] 开始执行任务 [ID: {job_id}, URL: {url}, Trigger: {job_trigger}, 时间: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}]")
# 根据任务类型分发执行(同步路径持有分布式锁,防多实例重复执行)
if url:
if is_sync == 1:
return _run_with_job_lock(job_id, _execute_by_url, job_id, url, is_sync, params)
# 异步路径:父线程立即返回,锁由子线程获取(见 _send_async_request)
return _execute_by_url(job_id, url, is_sync, params)
if job_trigger:
return _run_with_job_lock(job_id, _execute_by_trigger, job_trigger, job_id, params)
# 无效的任务配置
return {"success": False, "error": "任务既没有URL也没有触发器"}
def _run_with_job_lock(job_id: int, func, *args, **kwargs) -> Dict[str, Any]:
"""
在分布式锁保护下执行任务函数,保证同一任务只被一个调度器实例执行。
调用方负责传入实际执行函数;锁的获取/释放、异常兜底在此统一处理。
"""
lock = _JobExecutionLock(job_id)
if not lock.acquire():
logger.info(f"定时任务 [ID: {job_id}] 已被其他调度器实例执行,本次跳过")
print(f"[JobScheduler] 任务 [ID: {job_id}] 已被其他实例占用,本次跳过")
return {"success": False, "error": "任务已被其他调度器实例执行"}
try:
print(f"[scheduler] execute_job id={job_id} url={url} sync={is_sync} trigger={job_trigger} params={params}")
sys.stdout.flush()
logger.info(f"执行定时任务 [ID: {job_id}, URL: {url}, Trigger: {job_trigger}]")
# 根据任务类型分发执行
if url:
# URL方式:发送HTTP请求
result = _execute_by_url(job_id, url, is_sync, params)
elif job_trigger:
# 触发器方式:调用内部执行器
result = _execute_by_trigger(job_trigger, job_id, params)
else:
# 无效的任务配置
result = {"success": False, "error": "任务既没有URL也没有触发器"}
return result
return func(*args, **kwargs)
except Exception as e:
logger.error(f"执行任务异常 [ID: {job_id}]: {str(e)}")
return {"success": False, "error": str(e)}
finally:
lock.release()
# ============================================================
@@ -238,7 +363,7 @@ def _send_sync_request(url: str, data: Dict, job_id: int) -> Dict:
Returns:
Dict: 包含响应状态码和数据的执行结果
"""
try:
def _do() -> Dict:
logger.info(f"发送同步请求 [ID: {job_id}, URL: {url}]")
# 准备请求头
@@ -263,8 +388,8 @@ def _send_sync_request(url: str, data: Dict, job_id: int) -> Dict:
return {"success": True, "status_code": response.status_code, "data": result_data}
except requests.exceptions.RequestException as e:
return {"success": False, "error": str(e)}
# 同步执行(真实干活的地方):成功/失败/异常都写任务日志
return _execute_and_log(job_id, _do)
def _send_async_request(url: str, data: Dict, job_id: int) -> Dict:
@@ -285,19 +410,35 @@ def _send_async_request(url: str, data: Dict, job_id: int) -> Dict:
def async_request():
"""异步请求执行函数(在独立线程中运行)"""
# 多实例下每个 worker 都会触发该线程,必须加锁保证只执行一次
lock = _JobExecutionLock(job_id)
if not lock.acquire():
logger.info(f"异步任务 [ID: {job_id}] 已被其他调度器实例执行,本次跳过")
print(f"[JobScheduler] 异步任务 [ID: {job_id}] 已被其他实例占用,本次跳过")
return
try:
headers = {
"Content-Type": "application/json",
"X-Scheduler-Key": INTERNAL_API_KEY,
}
resp = requests.post(url, json=data, timeout=30, headers=headers)
logger.info(f"异步任务完成 [ID: {job_id}, 状态码: {resp.status_code}]")
def _do() -> Dict:
headers = {
"Content-Type": "application/json",
"X-Scheduler-Key": INTERNAL_API_KEY,
}
resp = requests.post(url, json=data, timeout=30, headers=headers)
logger.info(f"异步任务完成 [ID: {job_id}, 状态码: {resp.status_code}]")
print(f"[JobScheduler] 异步任务完成 [ID: {job_id}, 状态码: {resp.status_code}]")
return {"success": True, "status_code": resp.status_code,
"message": f"异步任务完成,状态码 {resp.status_code}"}
# 异步真实干活的地方:成功/失败/异常都写任务日志
_execute_and_log(job_id, _do)
except Exception as e:
logger.error(f"异步任务异常 [ID: {job_id}]: {str(e)}")
finally:
lock.release()
# 创建并启动独立线程执行异步请求
thread = threading.Thread(target=async_request)
thread.start()
print(f"[JobScheduler] 异步任务已触发 [ID: {job_id}, URL: {url}]")
return {"success": True, "status": "triggered"}
@@ -316,13 +457,14 @@ def _execute_by_trigger(trigger: str, job_id: int, params: Optional[Dict] = None
Returns:
Dict: 执行结果
"""
try:
def _do() -> Dict:
from src.modules.job.executor import job_executor
result = job_executor.execute(trigger, job_id=job_id, **(params or {}))
return {"success": True, "trigger": trigger, "data": result}
except Exception as e:
return {"success": False, "error": str(e)}
# 触发器任务真实干活的地方:成功/失败/异常都写任务日志
return _execute_and_log(job_id, _do)
# ============================================================
@@ -361,7 +503,7 @@ def add_job_to_scheduler(job_dict: dict) -> bool:
job_trigger = job_dict.get("jobTrigger")
job_name = job_dict.get("jobName")
print(f"[scheduler] add_job id={job_id} status={job_status} cron='{job_cron}'")
logger.debug(f"add_job id={job_id} status={job_status} cron='{job_cron}'")
# ============================================================
# 2. 参数校验
@@ -418,6 +560,7 @@ def add_job_to_scheduler(job_dict: dict) -> bool:
)
logger.info(f"任务已添加到调度器 [ID: {job_id}, cron: {job_cron}]")
print(f"[JobScheduler] 任务已添加到调度器 [ID: {job_id}, 名称: {job_name}, cron: {job_cron}, 下次执行: {next_run}]")
return True
except Exception as e:
@@ -528,6 +671,13 @@ def init_scheduler() -> int:
Returns:
int: 成功加载的任务数量
"""
global _SCHEDULER_APP
# 捕获 Flask app(供调度线程写任务日志时推 app 上下文;Scheduler.init_app 在 app 上下文内调用)
try:
from flask import current_app
_SCHEDULER_APP = current_app._get_current_object()
except Exception:
pass
try:
from src.modules.job.models import Job
@@ -535,6 +685,7 @@ def init_scheduler() -> int:
if not scheduler.running:
scheduler.start()
logger.info("调度器已启动")
print(f"[JobScheduler] 调度器已启动 [{datetime.now().strftime('%Y-%m-%d %H:%M:%S')}]")
# 查询所有启用的任务
jobs = Job.query.filter(Job.status == 1, Job.is_delete == 0).all()
@@ -553,6 +704,7 @@ def init_scheduler() -> int:
success_count += 1
logger.info(f"调度器初始化完成,加载 {success_count}/{len(job_dicts)} 个任务")
print(f"[JobScheduler] 调度器初始化完成,加载 {success_count}/{len(job_dicts)} 个任务")
return success_count
except Exception as e:
@@ -572,6 +724,7 @@ def reload_scheduler() -> int:
try:
# 清空所有现有任务
scheduler.remove_all_jobs()
print(f"[JobScheduler] 开始重新加载所有定时任务 [{datetime.now().strftime('%Y-%m-%d %H:%M:%S')}]")
# 重新初始化
return init_scheduler()
except Exception as e: