feat(schedule): move system jobs to DB-driven config and dashboard management
This commit is contained in:
+445
-149
@@ -1,10 +1,21 @@
|
||||
import asyncio
|
||||
import inspect
|
||||
import threading
|
||||
from collections import deque
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Callable, Awaitable, List, Dict, Optional, Any
|
||||
from typing import Callable, Awaitable, List, Dict, Optional, Any, Tuple
|
||||
|
||||
|
||||
class AsyncJob:
|
||||
"""异步定时任务中心。
|
||||
|
||||
设计目标:
|
||||
1. 保持装饰器用法兼容(原有插件和 main.py 不用重写)
|
||||
2. 支持运行中动态增删改任务
|
||||
3. 提供任务可观测信息(状态、下次运行、执行日志)
|
||||
4. 支持后台手动触发、启停、修改调度策略
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
self._jobs: Dict[str, Dict[str, Any]] = {}
|
||||
self._running_tasks: Dict[str, asyncio.Task] = {}
|
||||
@@ -35,34 +46,242 @@ class AsyncJob:
|
||||
return value
|
||||
return None
|
||||
|
||||
def _register(self, func: Callable, wrapper: Callable[[], Awaitable], trigger: str):
|
||||
@staticmethod
|
||||
def _safe_iso(dt: Optional[datetime]) -> Optional[str]:
|
||||
return dt.isoformat(timespec="seconds") if dt else None
|
||||
|
||||
@staticmethod
|
||||
def _normalize_time_str(time_str: str) -> str:
|
||||
text = str(time_str or "").strip()
|
||||
if not text:
|
||||
raise ValueError("时间不能为空")
|
||||
return datetime.strptime(text, "%H:%M").strftime("%H:%M")
|
||||
|
||||
def _normalize_trigger_config(self, trigger_type: str, trigger_config: Dict[str, Any]) -> Dict[str, Any]:
|
||||
if trigger_type == "every_seconds":
|
||||
seconds = int(trigger_config.get("seconds", 0))
|
||||
if seconds <= 0:
|
||||
raise ValueError("seconds 必须大于 0")
|
||||
return {"seconds": seconds}
|
||||
|
||||
if trigger_type == "at_times":
|
||||
time_list = trigger_config.get("time_list", [])
|
||||
if not isinstance(time_list, list) or not time_list:
|
||||
raise ValueError("time_list 不能为空")
|
||||
normalized = sorted(set(self._normalize_time_str(t) for t in time_list))
|
||||
return {"time_list": normalized}
|
||||
|
||||
if trigger_type == "every_weekday_time":
|
||||
weekday = int(trigger_config.get("weekday", -1))
|
||||
if weekday < 0 or weekday > 6:
|
||||
raise ValueError("weekday 必须在 0-6")
|
||||
return {"weekday": weekday, "time_str": self._normalize_time_str(trigger_config.get("time_str", ""))}
|
||||
|
||||
if trigger_type == "every_week_time":
|
||||
weekday = int(trigger_config.get("weekday", -1))
|
||||
if weekday < 0 or weekday > 6:
|
||||
raise ValueError("weekday 必须在 0-6")
|
||||
return {"weekday": weekday, "time_str": self._normalize_time_str(trigger_config.get("time_str", ""))}
|
||||
|
||||
if trigger_type == "every_month_last_day_time":
|
||||
return {"time_str": self._normalize_time_str(trigger_config.get("time_str", ""))}
|
||||
|
||||
raise ValueError(f"不支持的触发器类型: {trigger_type}")
|
||||
|
||||
def _format_trigger(self, trigger_type: str, trigger_config: Dict[str, Any]) -> str:
|
||||
if trigger_type == "every_seconds":
|
||||
return f"每 {trigger_config['seconds']} 秒"
|
||||
if trigger_type == "at_times":
|
||||
return "每天 " + ", ".join(trigger_config.get("time_list", []))
|
||||
if trigger_type == "every_weekday_time":
|
||||
return f"每周{trigger_config['weekday']} {trigger_config['time_str']}"
|
||||
if trigger_type == "every_week_time":
|
||||
return f"每周{trigger_config['weekday']} {trigger_config['time_str']}"
|
||||
if trigger_type == "every_month_last_day_time":
|
||||
return f"每月最后一天 {trigger_config['time_str']}"
|
||||
return trigger_type
|
||||
|
||||
def _compute_next_run(self, trigger_type: str, trigger_config: Dict[str, Any], now: Optional[datetime] = None) -> datetime:
|
||||
current = now or datetime.now()
|
||||
|
||||
if trigger_type == "every_seconds":
|
||||
return current + timedelta(seconds=int(trigger_config["seconds"]))
|
||||
|
||||
if trigger_type == "at_times":
|
||||
parsed_times = [datetime.strptime(t, "%H:%M").time() for t in trigger_config.get("time_list", [])]
|
||||
targets = []
|
||||
for t in parsed_times:
|
||||
target = datetime.combine(current.date(), t)
|
||||
if target <= current:
|
||||
target += timedelta(days=1)
|
||||
targets.append(target)
|
||||
return min(targets)
|
||||
|
||||
if trigger_type in ("every_weekday_time", "every_week_time"):
|
||||
weekday = int(trigger_config["weekday"])
|
||||
target_time = datetime.strptime(trigger_config["time_str"], "%H:%M").time()
|
||||
days_ahead = (weekday - current.weekday() + 7) % 7
|
||||
target_date = current.date() + timedelta(days=days_ahead)
|
||||
target_dt = datetime.combine(target_date, target_time)
|
||||
if target_dt <= current:
|
||||
target_dt += timedelta(days=7)
|
||||
return target_dt
|
||||
|
||||
if trigger_type == "every_month_last_day_time":
|
||||
target_time = datetime.strptime(trigger_config["time_str"], "%H:%M").time()
|
||||
if current.month == 12:
|
||||
next_month = datetime(current.year + 1, 1, 1)
|
||||
else:
|
||||
next_month = datetime(current.year, current.month + 1, 1)
|
||||
last_day = next_month - timedelta(days=1)
|
||||
target_dt = datetime.combine(last_day.date(), target_time)
|
||||
if target_dt <= current:
|
||||
if current.month == 12:
|
||||
next_month = datetime(current.year + 1, 2, 1)
|
||||
elif current.month == 11:
|
||||
next_month = datetime(current.year + 1, 1, 1)
|
||||
else:
|
||||
next_month = datetime(current.year, current.month + 2, 1)
|
||||
last_day = next_month - timedelta(days=1)
|
||||
target_dt = datetime.combine(last_day.date(), target_time)
|
||||
return target_dt
|
||||
|
||||
raise ValueError(f"未知触发器类型: {trigger_type}")
|
||||
|
||||
def _append_log(self, job: Dict[str, Any], level: str, message: str):
|
||||
logs: deque = job["logs"]
|
||||
logs.append(
|
||||
{
|
||||
"time": self._safe_iso(datetime.now()),
|
||||
"level": level,
|
||||
"message": message,
|
||||
}
|
||||
)
|
||||
|
||||
def _register(
|
||||
self,
|
||||
func: Callable,
|
||||
trigger_type: str,
|
||||
trigger_config: Dict[str, Any],
|
||||
job_name: Optional[str] = None,
|
||||
description: str = "",
|
||||
job_key: Optional[str] = None,
|
||||
):
|
||||
owner = self._infer_owner(func)
|
||||
normalized_config = self._normalize_trigger_config(trigger_type, trigger_config)
|
||||
job_id = self._next_job_id()
|
||||
display_name = (job_name or getattr(func, "__name__", None) or job_id).strip()
|
||||
owner_name = owner.__class__.__name__ if owner is not None else "system"
|
||||
created_at = datetime.now()
|
||||
|
||||
with self._lock:
|
||||
self._jobs[job_id] = {
|
||||
"id": job_id,
|
||||
"job_key": (job_key or "").strip(),
|
||||
"name": display_name,
|
||||
"description": description or "",
|
||||
"func": func,
|
||||
"wrapper": wrapper,
|
||||
"trigger": trigger,
|
||||
"trigger_type": trigger_type,
|
||||
"trigger_config": normalized_config,
|
||||
"trigger_text": self._format_trigger(trigger_type, normalized_config),
|
||||
"owner_id": id(owner) if owner is not None else None,
|
||||
"owner_name": owner.__class__.__name__ if owner is not None else None,
|
||||
"owner_name": owner_name,
|
||||
"enabled": True,
|
||||
"running": False,
|
||||
"last_run_at": None,
|
||||
"last_status": "never",
|
||||
"last_error": "",
|
||||
"last_duration_ms": None,
|
||||
"next_run_at": None,
|
||||
"run_count": 0,
|
||||
"success_count": 0,
|
||||
"fail_count": 0,
|
||||
"created_at": created_at,
|
||||
"updated_at": created_at,
|
||||
"logs": deque(maxlen=200),
|
||||
}
|
||||
self._append_log(self._jobs[job_id], "info", f"任务已注册: {display_name}")
|
||||
if self._running and self._loop and self._loop.is_running():
|
||||
self._loop.call_soon_threadsafe(self._start_job_in_loop, job_id)
|
||||
return job_id
|
||||
|
||||
async def _execute_job(self, job_id: str, reason: str = "schedule") -> Tuple[bool, str]:
|
||||
job = self._jobs.get(job_id)
|
||||
if not job:
|
||||
return False, "任务不存在"
|
||||
|
||||
if job.get("running"):
|
||||
return False, "任务正在执行中"
|
||||
|
||||
func = job["func"]
|
||||
started_at = datetime.now()
|
||||
job["running"] = True
|
||||
job["last_run_at"] = started_at
|
||||
self._append_log(job, "info", f"开始执行,触发来源: {reason}")
|
||||
|
||||
try:
|
||||
result = func()
|
||||
if inspect.isawaitable(result):
|
||||
await result
|
||||
job["last_status"] = "success"
|
||||
job["last_error"] = ""
|
||||
job["success_count"] += 1
|
||||
self._append_log(job, "success", "执行成功")
|
||||
return True, "执行成功"
|
||||
except asyncio.CancelledError:
|
||||
job["last_status"] = "cancelled"
|
||||
job["last_error"] = "任务被取消"
|
||||
self._append_log(job, "warning", "任务被取消")
|
||||
raise
|
||||
except Exception as e:
|
||||
job["last_status"] = "failed"
|
||||
job["last_error"] = str(e)
|
||||
job["fail_count"] += 1
|
||||
self._append_log(job, "error", f"执行失败: {e}")
|
||||
return False, str(e)
|
||||
finally:
|
||||
finished_at = datetime.now()
|
||||
job["run_count"] += 1
|
||||
job["running"] = False
|
||||
job["last_duration_ms"] = int((finished_at - started_at).total_seconds() * 1000)
|
||||
job["updated_at"] = finished_at
|
||||
|
||||
async def _job_loop(self, job_id: str):
|
||||
while True:
|
||||
job = self._jobs.get(job_id)
|
||||
if not job:
|
||||
return
|
||||
|
||||
if not job.get("enabled", True):
|
||||
job["next_run_at"] = None
|
||||
await asyncio.sleep(1)
|
||||
continue
|
||||
|
||||
try:
|
||||
next_run = self._compute_next_run(job["trigger_type"], job["trigger_config"])
|
||||
except Exception as e:
|
||||
job["last_status"] = "invalid_schedule"
|
||||
job["last_error"] = str(e)
|
||||
job["next_run_at"] = None
|
||||
self._append_log(job, "error", f"调度配置非法: {e}")
|
||||
await asyncio.sleep(5)
|
||||
continue
|
||||
|
||||
job["next_run_at"] = next_run
|
||||
wait_seconds = max((next_run - datetime.now()).total_seconds(), 0)
|
||||
await asyncio.sleep(wait_seconds)
|
||||
|
||||
# 睡眠结束后再检查一次,避免刚被禁用/删除还执行
|
||||
job = self._jobs.get(job_id)
|
||||
if not job or not job.get("enabled", True):
|
||||
continue
|
||||
|
||||
await self._execute_job(job_id, reason="schedule")
|
||||
|
||||
def _start_job_in_loop(self, job_id: str):
|
||||
job = self._jobs.get(job_id)
|
||||
if not job or job_id in self._running_tasks:
|
||||
if job_id in self._running_tasks:
|
||||
return
|
||||
|
||||
async def runner():
|
||||
try:
|
||||
await job["wrapper"]()
|
||||
except asyncio.CancelledError:
|
||||
raise
|
||||
except Exception as e:
|
||||
print(f"[AsyncJob] 任务异常退出: {job_id}, trigger={job.get('trigger')}, error={e}")
|
||||
|
||||
task = asyncio.create_task(runner(), name=f"async_job:{job_id}")
|
||||
task = asyncio.create_task(self._job_loop(job_id), name=f"async_job:{job_id}")
|
||||
self._running_tasks[job_id] = task
|
||||
task.add_done_callback(lambda _task, _id=job_id: self._running_tasks.pop(_id, None))
|
||||
|
||||
@@ -71,6 +290,11 @@ class AsyncJob:
|
||||
if task:
|
||||
task.cancel()
|
||||
|
||||
def _restart_job_in_loop(self, job_id: str):
|
||||
self._cancel_job_in_loop(job_id)
|
||||
if job_id in self._jobs:
|
||||
self._start_job_in_loop(job_id)
|
||||
|
||||
def remove_job(self, job_id: str) -> bool:
|
||||
with self._lock:
|
||||
existed = job_id in self._jobs or job_id in self._running_tasks
|
||||
@@ -97,161 +321,233 @@ class AsyncJob:
|
||||
removed += 1
|
||||
return removed
|
||||
|
||||
def every_seconds(self, seconds: int):
|
||||
def decorator(func: Callable):
|
||||
async def wrapper():
|
||||
while True:
|
||||
try:
|
||||
await func()
|
||||
except asyncio.CancelledError:
|
||||
raise
|
||||
except Exception as e:
|
||||
print(f"[AsyncJob] every_seconds 任务执行异常: {e}")
|
||||
await asyncio.sleep(seconds)
|
||||
def get_jobs_snapshot(self) -> List[Dict[str, Any]]:
|
||||
with self._lock:
|
||||
snapshots = []
|
||||
for job in self._jobs.values():
|
||||
snapshots.append(
|
||||
{
|
||||
"id": job["id"],
|
||||
"job_key": job.get("job_key", ""),
|
||||
"name": job["name"],
|
||||
"description": job.get("description", ""),
|
||||
"trigger_type": job["trigger_type"],
|
||||
"trigger_config": dict(job["trigger_config"]),
|
||||
"trigger_text": job.get("trigger_text", ""),
|
||||
"owner_name": job.get("owner_name", "system"),
|
||||
"enabled": job.get("enabled", True),
|
||||
"running": job.get("running", False),
|
||||
"last_run_at": self._safe_iso(job.get("last_run_at")),
|
||||
"last_status": job.get("last_status", "never"),
|
||||
"last_error": job.get("last_error", ""),
|
||||
"last_duration_ms": job.get("last_duration_ms"),
|
||||
"next_run_at": self._safe_iso(job.get("next_run_at")),
|
||||
"run_count": job.get("run_count", 0),
|
||||
"success_count": job.get("success_count", 0),
|
||||
"fail_count": job.get("fail_count", 0),
|
||||
"created_at": self._safe_iso(job.get("created_at")),
|
||||
"updated_at": self._safe_iso(job.get("updated_at")),
|
||||
}
|
||||
)
|
||||
snapshots.sort(key=lambda item: item["name"])
|
||||
return snapshots
|
||||
|
||||
self._register(func, wrapper, f"every_seconds({seconds})")
|
||||
def get_job_logs(self, job_id: str, limit: int = 100) -> List[Dict[str, Any]]:
|
||||
with self._lock:
|
||||
job = self._jobs.get(job_id)
|
||||
if not job:
|
||||
return []
|
||||
logs: deque = job.get("logs", deque())
|
||||
data = list(logs)
|
||||
if limit > 0:
|
||||
data = data[-limit:]
|
||||
return data
|
||||
|
||||
def trigger_job_now(self, job_id: str, operator: str = "dashboard") -> Tuple[bool, str]:
|
||||
with self._lock:
|
||||
job = self._jobs.get(job_id)
|
||||
loop = self._loop
|
||||
running = self._running
|
||||
|
||||
if not job:
|
||||
return False, "任务不存在"
|
||||
if job.get("running"):
|
||||
return False, "任务正在执行中"
|
||||
if not running or not loop or not loop.is_running():
|
||||
return False, "任务调度器未运行"
|
||||
|
||||
def _trigger():
|
||||
asyncio.create_task(self._execute_job(job_id, reason=f"manual:{operator}"))
|
||||
|
||||
loop.call_soon_threadsafe(_trigger)
|
||||
return True, "任务已触发"
|
||||
|
||||
def get_job_id_by_key(self, job_key: str) -> Optional[str]:
|
||||
key = str(job_key or "").strip()
|
||||
if not key:
|
||||
return None
|
||||
with self._lock:
|
||||
for job_id, job in self._jobs.items():
|
||||
if job.get("job_key") == key:
|
||||
return job_id
|
||||
return None
|
||||
|
||||
def register_callable(
|
||||
self,
|
||||
func: Callable,
|
||||
trigger_type: str,
|
||||
trigger_config: Dict[str, Any],
|
||||
job_name: Optional[str] = None,
|
||||
description: str = "",
|
||||
job_key: Optional[str] = None,
|
||||
) -> str:
|
||||
"""运行时注册任务(非装饰器方式)。"""
|
||||
return self._register(
|
||||
func=func,
|
||||
trigger_type=trigger_type,
|
||||
trigger_config=trigger_config,
|
||||
job_name=job_name,
|
||||
description=description,
|
||||
job_key=job_key,
|
||||
)
|
||||
|
||||
def set_job_enabled(self, job_id: str, enabled: bool) -> Tuple[bool, str]:
|
||||
with self._lock:
|
||||
job = self._jobs.get(job_id)
|
||||
loop = self._loop
|
||||
running = self._running
|
||||
if not job:
|
||||
return False, "任务不存在"
|
||||
job["enabled"] = bool(enabled)
|
||||
job["updated_at"] = datetime.now()
|
||||
state_text = "启用" if enabled else "停用"
|
||||
self._append_log(job, "info", f"任务已{state_text}")
|
||||
|
||||
if running and loop and loop.is_running():
|
||||
loop.call_soon_threadsafe(self._restart_job_in_loop, job_id)
|
||||
return True, f"任务已{state_text}"
|
||||
|
||||
def update_job_schedule(self, job_id: str, trigger_type: str, trigger_config: Dict[str, Any]) -> Tuple[bool, str]:
|
||||
try:
|
||||
normalized = self._normalize_trigger_config(trigger_type, trigger_config)
|
||||
except Exception as e:
|
||||
return False, f"调度参数非法: {e}"
|
||||
|
||||
with self._lock:
|
||||
job = self._jobs.get(job_id)
|
||||
loop = self._loop
|
||||
running = self._running
|
||||
if not job:
|
||||
return False, "任务不存在"
|
||||
|
||||
job["trigger_type"] = trigger_type
|
||||
job["trigger_config"] = normalized
|
||||
job["trigger_text"] = self._format_trigger(trigger_type, normalized)
|
||||
job["updated_at"] = datetime.now()
|
||||
self._append_log(job, "info", f"调度已更新: {job['trigger_text']}")
|
||||
|
||||
if running and loop and loop.is_running():
|
||||
loop.call_soon_threadsafe(self._restart_job_in_loop, job_id)
|
||||
return True, "调度更新成功"
|
||||
|
||||
def every_seconds(self, seconds: int, job_name: Optional[str] = None, description: str = "", job_key: Optional[str] = None):
|
||||
def decorator(func: Callable):
|
||||
self._register(
|
||||
func=func,
|
||||
trigger_type="every_seconds",
|
||||
trigger_config={"seconds": seconds},
|
||||
job_name=job_name,
|
||||
description=description,
|
||||
job_key=job_key,
|
||||
)
|
||||
return func
|
||||
|
||||
return decorator
|
||||
|
||||
def every_minutes(self, minutes: int):
|
||||
return self.every_seconds(minutes * 60)
|
||||
def every_minutes(self, minutes: int, job_name: Optional[str] = None, description: str = "", job_key: Optional[str] = None):
|
||||
return self.every_seconds(minutes * 60, job_name=job_name, description=description, job_key=job_key)
|
||||
|
||||
def every_hours(self, hours: int):
|
||||
return self.every_seconds(hours * 3600)
|
||||
def every_hours(self, hours: int, job_name: Optional[str] = None, description: str = "", job_key: Optional[str] = None):
|
||||
return self.every_seconds(hours * 3600, job_name=job_name, description=description, job_key=job_key)
|
||||
|
||||
def at_times(self, time_list: List[str]):
|
||||
def at_times(
|
||||
self,
|
||||
time_list: List[str],
|
||||
job_name: Optional[str] = None,
|
||||
description: str = "",
|
||||
job_key: Optional[str] = None,
|
||||
):
|
||||
def decorator(func: Callable):
|
||||
parsed_times = [datetime.strptime(t, "%H:%M").time() for t in time_list]
|
||||
|
||||
async def wrapper():
|
||||
while True:
|
||||
now = datetime.now()
|
||||
targets = []
|
||||
for t in parsed_times:
|
||||
target = datetime.combine(now.date(), t)
|
||||
if target <= now:
|
||||
target += timedelta(days=1)
|
||||
targets.append(target)
|
||||
|
||||
next_target = min(targets)
|
||||
wait_seconds = (next_target - now).total_seconds()
|
||||
await asyncio.sleep(max(wait_seconds, 0))
|
||||
try:
|
||||
await func()
|
||||
except asyncio.CancelledError:
|
||||
raise
|
||||
except Exception as e:
|
||||
print(f"[AsyncJob] at_times 任务执行异常: {e}")
|
||||
|
||||
self._register(func, wrapper, f"at_times({time_list})")
|
||||
self._register(
|
||||
func=func,
|
||||
trigger_type="at_times",
|
||||
trigger_config={"time_list": time_list},
|
||||
job_name=job_name,
|
||||
description=description,
|
||||
job_key=job_key,
|
||||
)
|
||||
return func
|
||||
|
||||
return decorator
|
||||
|
||||
def every_weekday_time(self, weekday: int, time_str: str):
|
||||
"""
|
||||
每周 weekday(0=周一) 的 time_str(如10:00)时间执行
|
||||
"""
|
||||
|
||||
def every_weekday_time(
|
||||
self,
|
||||
weekday: int,
|
||||
time_str: str,
|
||||
job_name: Optional[str] = None,
|
||||
description: str = "",
|
||||
job_key: Optional[str] = None,
|
||||
):
|
||||
def decorator(func: Callable):
|
||||
async def wrapper():
|
||||
while True:
|
||||
now = datetime.now()
|
||||
target_time = datetime.strptime(time_str, "%H:%M").time()
|
||||
|
||||
# 构造下一个执行时间
|
||||
days_ahead = (weekday - now.weekday() + 7) % 7
|
||||
target_date = now.date() + timedelta(days=days_ahead)
|
||||
target_dt = datetime.combine(target_date, target_time)
|
||||
|
||||
if target_dt <= now:
|
||||
target_dt += timedelta(days=7)
|
||||
|
||||
sleep_secs = (target_dt - now).total_seconds()
|
||||
await asyncio.sleep(sleep_secs)
|
||||
try:
|
||||
await func()
|
||||
except asyncio.CancelledError:
|
||||
raise
|
||||
except Exception as e:
|
||||
print(f"[AsyncJob] every_weekday_time 任务执行异常: {e}")
|
||||
|
||||
self._register(func, wrapper, f"every_weekday_time({weekday}, {time_str})")
|
||||
self._register(
|
||||
func=func,
|
||||
trigger_type="every_weekday_time",
|
||||
trigger_config={"weekday": weekday, "time_str": time_str},
|
||||
job_name=job_name,
|
||||
description=description,
|
||||
job_key=job_key,
|
||||
)
|
||||
return func
|
||||
|
||||
return decorator
|
||||
|
||||
def every_week_time(self, weekday: int, time_str: str):
|
||||
"""
|
||||
每周 weekday(0=周一,6=周日) 的 time_str 时间执行
|
||||
"""
|
||||
|
||||
def every_week_time(
|
||||
self,
|
||||
weekday: int,
|
||||
time_str: str,
|
||||
job_name: Optional[str] = None,
|
||||
description: str = "",
|
||||
job_key: Optional[str] = None,
|
||||
):
|
||||
def decorator(func: Callable):
|
||||
async def wrapper():
|
||||
while True:
|
||||
now = datetime.now()
|
||||
target_time = datetime.strptime(time_str, "%H:%M").time()
|
||||
|
||||
days_ahead = (weekday - now.weekday() + 7) % 7
|
||||
target_date = now.date() + timedelta(days=days_ahead)
|
||||
target_dt = datetime.combine(target_date, target_time)
|
||||
|
||||
if target_dt <= now:
|
||||
target_dt += timedelta(days=7)
|
||||
|
||||
sleep_secs = (target_dt - now).total_seconds()
|
||||
await asyncio.sleep(sleep_secs)
|
||||
try:
|
||||
await func()
|
||||
except asyncio.CancelledError:
|
||||
raise
|
||||
except Exception as e:
|
||||
print(f"[AsyncJob] every_week_time 任务执行异常: {e}")
|
||||
|
||||
self._register(func, wrapper, f"every_week_time({weekday}, {time_str})")
|
||||
self._register(
|
||||
func=func,
|
||||
trigger_type="every_week_time",
|
||||
trigger_config={"weekday": weekday, "time_str": time_str},
|
||||
job_name=job_name,
|
||||
description=description,
|
||||
job_key=job_key,
|
||||
)
|
||||
return func
|
||||
|
||||
return decorator
|
||||
|
||||
def every_month_last_day_time(self, time_str: str):
|
||||
"""
|
||||
每月最后一天的 time_str 时间执行
|
||||
"""
|
||||
|
||||
def every_month_last_day_time(
|
||||
self,
|
||||
time_str: str,
|
||||
job_name: Optional[str] = None,
|
||||
description: str = "",
|
||||
job_key: Optional[str] = None,
|
||||
):
|
||||
def decorator(func: Callable):
|
||||
async def wrapper():
|
||||
while True:
|
||||
now = datetime.now()
|
||||
target_time = datetime.strptime(time_str, "%H:%M").time()
|
||||
|
||||
if now.month == 12:
|
||||
next_month = datetime(now.year + 1, 1, 1)
|
||||
else:
|
||||
next_month = datetime(now.year, now.month + 1, 1)
|
||||
last_day = next_month - timedelta(days=1)
|
||||
target_dt = datetime.combine(last_day.date(), target_time)
|
||||
|
||||
if target_dt <= now:
|
||||
if now.month == 12:
|
||||
next_month = datetime(now.year + 1, 2, 1)
|
||||
elif now.month == 11:
|
||||
next_month = datetime(now.year + 1, 1, 1)
|
||||
else:
|
||||
next_month = datetime(now.year, now.month + 2, 1)
|
||||
last_day = next_month - timedelta(days=1)
|
||||
target_dt = datetime.combine(last_day.date(), target_time)
|
||||
|
||||
sleep_secs = (target_dt - now).total_seconds()
|
||||
await asyncio.sleep(sleep_secs)
|
||||
try:
|
||||
await func()
|
||||
except asyncio.CancelledError:
|
||||
raise
|
||||
except Exception as e:
|
||||
print(f"[AsyncJob] every_month_last_day_time 任务执行异常: {e}")
|
||||
|
||||
self._register(func, wrapper, f"every_month_last_day_time({time_str})")
|
||||
self._register(
|
||||
func=func,
|
||||
trigger_type="every_month_last_day_time",
|
||||
trigger_config={"time_str": time_str},
|
||||
job_name=job_name,
|
||||
description=description,
|
||||
job_key=job_key,
|
||||
)
|
||||
return func
|
||||
|
||||
return decorator
|
||||
|
||||
Reference in New Issue
Block a user