mirror of
https://github.com/countbot-ai/CountBot.git
synced 2026-09-14 20:46:47 +08:00
298 lines
9.4 KiB
Python
298 lines
9.4 KiB
Python
"""Cron 定时任务服务"""
|
|
|
|
import asyncio
|
|
import uuid
|
|
from datetime import datetime, timezone, timedelta
|
|
from typing import Dict, List, Optional
|
|
|
|
from croniter import croniter
|
|
from sqlalchemy import select
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
|
|
from backend.models.cron_job import CronJob
|
|
from backend.modules.cron.types import CronJobInfo, JobExecutionResult, CronSchedule
|
|
from backend.utils.logger import logger
|
|
|
|
# 北京时区 UTC+8
|
|
SHANGHAI_TZ = timezone(timedelta(hours=8))
|
|
|
|
|
|
class CronService:
|
|
"""Cron 定时任务服务"""
|
|
|
|
def __init__(self, db: AsyncSession, scheduler=None):
|
|
self.db = db
|
|
self.scheduler = scheduler
|
|
self._running_jobs: Dict[str, asyncio.Task] = {}
|
|
|
|
async def add_job(
|
|
self,
|
|
name: str,
|
|
schedule: str,
|
|
message: str,
|
|
enabled: bool = True,
|
|
channel: Optional[str] = None,
|
|
account_id: Optional[str] = None,
|
|
chat_id: Optional[str] = None,
|
|
deliver_response: bool = False,
|
|
max_retries: int = 0,
|
|
retry_delay: int = 60,
|
|
delete_on_success: bool = False
|
|
) -> CronJob:
|
|
"""添加定时任务"""
|
|
if not self.validate_schedule(schedule):
|
|
raise ValueError(f"Invalid cron: {schedule}")
|
|
|
|
# 调试日志
|
|
logger.info(f"Creating job with delete_on_success={delete_on_success} (type: {type(delete_on_success)})")
|
|
|
|
next_run = self.calculate_next_run(schedule) if enabled else None
|
|
|
|
job = CronJob(
|
|
id=str(uuid.uuid4()),
|
|
name=name,
|
|
schedule=schedule,
|
|
message=message,
|
|
enabled=enabled,
|
|
channel=channel,
|
|
account_id=account_id,
|
|
chat_id=chat_id,
|
|
deliver_response=deliver_response,
|
|
max_retries=max_retries,
|
|
retry_delay=retry_delay,
|
|
delete_on_success=delete_on_success,
|
|
next_run=next_run,
|
|
created_at=datetime.now(SHANGHAI_TZ).replace(tzinfo=None),
|
|
updated_at=datetime.now(SHANGHAI_TZ).replace(tzinfo=None)
|
|
)
|
|
|
|
self.db.add(job)
|
|
await self.db.commit()
|
|
await self.db.refresh(job)
|
|
|
|
logger.info(f"Created job: {name} ({job.id}), delete_on_success={job.delete_on_success}")
|
|
|
|
if self.scheduler and enabled:
|
|
try:
|
|
await self.scheduler.trigger_reschedule()
|
|
except Exception as e:
|
|
logger.error(f"Failed to reschedule: {e}")
|
|
|
|
return job
|
|
|
|
async def get_job(self, job_id: str) -> Optional[CronJob]:
|
|
"""获取任务"""
|
|
result = await self.db.execute(
|
|
select(CronJob).where(CronJob.id == job_id)
|
|
)
|
|
return result.scalar_one_or_none()
|
|
|
|
async def list_jobs(self, enabled_only: bool = False) -> List[CronJob]:
|
|
"""列出所有任务"""
|
|
query = select(CronJob).order_by(CronJob.created_at.desc())
|
|
|
|
if enabled_only:
|
|
query = query.where(CronJob.enabled == True)
|
|
|
|
result = await self.db.execute(query)
|
|
return list(result.scalars().all())
|
|
|
|
async def update_job(
|
|
self,
|
|
job_id: str,
|
|
name: Optional[str] = None,
|
|
schedule: Optional[str] = None,
|
|
message: Optional[str] = None,
|
|
enabled: Optional[bool] = None,
|
|
channel: Optional[str] = None,
|
|
account_id: Optional[str] = None,
|
|
chat_id: Optional[str] = None,
|
|
deliver_response: Optional[bool] = None,
|
|
max_retries: Optional[int] = None,
|
|
retry_delay: Optional[int] = None,
|
|
delete_on_success: Optional[bool] = None
|
|
) -> Optional[CronJob]:
|
|
"""更新任务"""
|
|
job = await self.get_job(job_id)
|
|
if job is None:
|
|
return None
|
|
|
|
if name is not None:
|
|
job.name = name
|
|
|
|
if schedule is not None:
|
|
if not self.validate_schedule(schedule):
|
|
raise ValueError(f"Invalid cron: {schedule}")
|
|
job.schedule = schedule
|
|
|
|
if message is not None:
|
|
job.message = message
|
|
|
|
if enabled is not None:
|
|
job.enabled = enabled
|
|
|
|
if channel is not None:
|
|
job.channel = channel
|
|
|
|
if account_id is not None:
|
|
job.account_id = account_id
|
|
|
|
if chat_id is not None:
|
|
job.chat_id = chat_id
|
|
|
|
if deliver_response is not None:
|
|
job.deliver_response = deliver_response
|
|
|
|
if max_retries is not None:
|
|
job.max_retries = max_retries
|
|
|
|
if retry_delay is not None:
|
|
job.retry_delay = retry_delay
|
|
|
|
if delete_on_success is not None:
|
|
job.delete_on_success = delete_on_success
|
|
|
|
if job.enabled:
|
|
job.next_run = self.calculate_next_run(job.schedule)
|
|
else:
|
|
job.next_run = None
|
|
|
|
job.updated_at = datetime.now(SHANGHAI_TZ).replace(tzinfo=None)
|
|
|
|
await self.db.commit()
|
|
await self.db.refresh(job)
|
|
|
|
logger.info(f"Updated job: {job.name} ({job_id})")
|
|
|
|
if self.scheduler:
|
|
try:
|
|
await self.scheduler.trigger_reschedule()
|
|
except Exception as e:
|
|
logger.error(f"Failed to reschedule: {e}")
|
|
|
|
return job
|
|
|
|
async def delete_job(self, job_id: str) -> bool:
|
|
"""删除任务"""
|
|
job = await self.get_job(job_id)
|
|
if job is None:
|
|
return False
|
|
|
|
if job_id in self._running_jobs:
|
|
self._running_jobs[job_id].cancel()
|
|
del self._running_jobs[job_id]
|
|
|
|
await self.db.delete(job)
|
|
await self.db.commit()
|
|
|
|
logger.info(f"Deleted job: {job.name} ({job_id})")
|
|
|
|
if self.scheduler:
|
|
try:
|
|
await self.scheduler.trigger_reschedule()
|
|
except Exception as e:
|
|
logger.error(f"Failed to reschedule: {e}")
|
|
|
|
return True
|
|
|
|
async def get_due_jobs(self) -> List[CronJob]:
|
|
"""获取到期任务(基于北京时间)"""
|
|
now = datetime.now(SHANGHAI_TZ).replace(tzinfo=None)
|
|
result = await self.db.execute(
|
|
select(CronJob)
|
|
.where(CronJob.enabled == True)
|
|
.where(CronJob.next_run <= now)
|
|
.order_by(CronJob.next_run.asc())
|
|
)
|
|
return list(result.scalars().all())
|
|
|
|
def validate_schedule(self, schedule: str) -> bool:
|
|
"""验证 Cron 表达式"""
|
|
try:
|
|
croniter(schedule)
|
|
return True
|
|
except Exception:
|
|
return False
|
|
|
|
def calculate_next_run(
|
|
self,
|
|
schedule: str,
|
|
base_time: Optional[datetime] = None
|
|
) -> datetime:
|
|
"""计算下次运行时间(基于北京时间)"""
|
|
if base_time is None:
|
|
base_time = datetime.now(SHANGHAI_TZ).replace(tzinfo=None)
|
|
|
|
try:
|
|
cron = croniter(schedule, base_time)
|
|
return cron.get_next(datetime)
|
|
except Exception as e:
|
|
raise ValueError(f"Invalid cron: {schedule}") from e
|
|
|
|
def get_schedule_description(self, schedule: str) -> str:
|
|
"""获取 Cron 表达式描述"""
|
|
try:
|
|
parts = schedule.split()
|
|
if len(parts) != 5:
|
|
return schedule
|
|
|
|
minute, hour, day, month, weekday = parts
|
|
descriptions = []
|
|
|
|
if minute == "*":
|
|
descriptions.append("每分钟")
|
|
elif minute.startswith("*/"):
|
|
descriptions.append(f"每 {minute[2:]} 分钟")
|
|
else:
|
|
descriptions.append(f"在第 {minute} 分钟")
|
|
|
|
if hour == "*":
|
|
descriptions.append("每小时")
|
|
elif hour.startswith("*/"):
|
|
descriptions.append(f"每 {hour[2:]} 小时")
|
|
else:
|
|
descriptions.append(f"在 {hour} 点")
|
|
|
|
if day != "*":
|
|
descriptions.append(f"每月第 {day} 天")
|
|
|
|
if month != "*":
|
|
descriptions.append(f"在 {month} 月")
|
|
|
|
if weekday != "*":
|
|
weekday_names = {
|
|
"0": "周日", "1": "周一", "2": "周二",
|
|
"3": "周三", "4": "周四", "5": "周五", "6": "周六"
|
|
}
|
|
descriptions.append(f"在{weekday_names.get(weekday, weekday)}")
|
|
|
|
return " ".join(descriptions)
|
|
|
|
except Exception:
|
|
return schedule
|
|
|
|
def to_job_info(self, job: CronJob) -> CronJobInfo:
|
|
"""转换为 CronJobInfo"""
|
|
return CronJobInfo(
|
|
id=job.id,
|
|
name=job.name,
|
|
schedule=job.schedule,
|
|
message=job.message,
|
|
enabled=job.enabled,
|
|
last_run=job.last_run,
|
|
next_run=job.next_run,
|
|
created_at=job.created_at
|
|
)
|
|
|
|
async def get_job_info(self, job_id: str) -> Optional[CronJobInfo]:
|
|
"""获取任务信息"""
|
|
job = await self.get_job(job_id)
|
|
if job is None:
|
|
return None
|
|
return self.to_job_info(job)
|
|
|
|
async def list_job_infos(self, enabled_only: bool = False) -> List[CronJobInfo]:
|
|
"""列出所有任务信息"""
|
|
jobs = await self.list_jobs(enabled_only=enabled_only)
|
|
return [self.to_job_info(job) for job in jobs]
|