1. ticket_scheduler.py: 纯asyncio→APScheduler+SQLite持久化 - 服务重启后定时任务不丢失 - 启动时自动恢复scheduled状态工单 - shutdown时优雅关闭scheduler 2. TicketCreatePage.tsx: - 提交按钮旁新增SQL检测按钮(Shield图标) - 验证通过前提交按钮置灰+提示'请先通过SQL检测' - SQL内容变化自动重置验证状态 - 定时时间改为醒目红色字体(text-red-500 font-semibold) 3. main.py: lifespan中启动APScheduler+恢复定时工单
243 lines
8.1 KiB
Python
243 lines
8.1 KiB
Python
"""工单定时执行调度器 — 基于 APScheduler + SQLite 持久化。
|
||
|
||
优势(对比纯 asyncio):
|
||
1. 服务重启后定时任务不丢失(SQLite job store 持久化)
|
||
2. 启动时自动恢复 scheduled 状态的工单
|
||
3. 支持任务重试、misfire_grace_time 等生产级特性
|
||
"""
|
||
import asyncio
|
||
import logging
|
||
import os
|
||
from datetime import datetime, timezone
|
||
from typing import Optional
|
||
|
||
from apscheduler.schedulers.asyncio import AsyncIOScheduler
|
||
from apscheduler.jobstores.sqlalchemy import SQLAlchemyJobStore
|
||
from apscheduler.triggers.date import DateTrigger
|
||
|
||
logger = logging.getLogger(__name__)
|
||
|
||
# 全局调度器实例(延迟初始化)
|
||
_scheduler: Optional[AsyncIOScheduler] = None
|
||
|
||
DB_DIR = os.path.join(os.path.dirname(os.path.dirname(__file__)), '..', 'apscheduler.db')
|
||
|
||
|
||
def _get_scheduler() -> AsyncIOScheduler:
|
||
"""获取或创建 APScheduler 实例(单例)。"""
|
||
global _scheduler
|
||
if _scheduler is not None and _scheduler.running:
|
||
return _scheduler
|
||
|
||
jobstore_url = 'sqlite:///' + os.path.abspath(DB_DIR)
|
||
_scheduler = AsyncIOScheduler(
|
||
jobstores={'default': SQLAlchemyJobStore(url=jobstore_url)},
|
||
job_defaults={
|
||
'coalesce': True, # 同一 job 只执行一次
|
||
'max_instances': 1, # 不并发
|
||
'misfire_grace_time': 300, # 5 分钟内迟到仍执行
|
||
},
|
||
)
|
||
return _scheduler
|
||
|
||
|
||
def start_scheduler() -> None:
|
||
"""启动调度器(应用启动时调用)。"""
|
||
scheduler = _get_scheduler()
|
||
if not scheduler.running:
|
||
scheduler.start()
|
||
logger.info("APScheduler 已启动 (SQLite 持久化)")
|
||
|
||
|
||
def shutdown_scheduler() -> None:
|
||
"""关闭调度器。"""
|
||
global _scheduler
|
||
if _scheduler and _scheduler.running:
|
||
_scheduler.shutdown(wait=False)
|
||
logger.info("APScheduler 已关闭")
|
||
|
||
|
||
async def schedule_ticket_execution(ticket_id: int, scheduled_at: datetime) -> None:
|
||
"""注册一个定时执行任务。
|
||
|
||
Args:
|
||
ticket_id: 工单 ID
|
||
scheduled_at: 定时执行时间 (UTC)
|
||
"""
|
||
scheduler = _get_scheduler()
|
||
job_id = f"ticket_{ticket_id}"
|
||
|
||
now = datetime.now(timezone.utc)
|
||
run_date = scheduled_at if scheduled_at.tzinfo else scheduled_at.replace(tzinfo=timezone.utc)
|
||
|
||
if run_date <= now:
|
||
# 已到时间,立即执行
|
||
logger.info("工单 %d 定时时间已到,立即执行", ticket_id)
|
||
await _execute_scheduled_ticket(ticket_id)
|
||
return
|
||
|
||
delay = (run_date - now).total_seconds()
|
||
logger.info("工单 %d 定时执行: %s (%.1f 秒后)", ticket_id, scheduled_at, delay)
|
||
|
||
scheduler.add_job(
|
||
_run_async_execution,
|
||
trigger=DateTrigger(run_date=run_date),
|
||
args=[ticket_id],
|
||
id=job_id,
|
||
replace_existing=True,
|
||
name=f"定时执行工单 #{ticket_id}",
|
||
)
|
||
|
||
|
||
async def _run_async_execution(ticket_id: int) -> None:
|
||
"""APScheduler 调用的入口(同步 wrapper → 异步执行)。"""
|
||
await _execute_scheduled_ticket(ticket_id)
|
||
|
||
|
||
async def _execute_scheduled_ticket(ticket_id: int) -> None:
|
||
"""执行定时工单 — 创建独立数据库会话执行。"""
|
||
from app.core.database import async_session
|
||
from app.models.ticket import Ticket as TicketModel
|
||
from app.models.instance import Instance
|
||
from app.services.ticket_executor import TicketExecutor
|
||
from app.core.security import decrypt_password
|
||
from sqlalchemy import select
|
||
import json
|
||
|
||
logger.info("开始执行定时工单 %d", ticket_id)
|
||
|
||
async with async_session() as db:
|
||
try:
|
||
result = await db.execute(select(TicketModel).where(TicketModel.id == ticket_id))
|
||
ticket = result.scalar_one_or_none()
|
||
if not ticket:
|
||
logger.error("定时工单 %d 不存在", ticket_id)
|
||
return
|
||
|
||
if ticket.status != "scheduled":
|
||
logger.warning("工单 %d 状态已变更 (%s),跳过执行", ticket_id, ticket.status)
|
||
return
|
||
|
||
ticket.status = "executing"
|
||
await db.commit()
|
||
await db.refresh(ticket)
|
||
|
||
inst_result = await db.execute(select(Instance).where(Instance.id == ticket.instance_id))
|
||
instance = inst_result.scalar_one_or_none()
|
||
if not instance:
|
||
ticket.status = "failed"
|
||
ticket.execute_result = json.dumps({"status": "failed", "errors": ["数据源实例不存在"]}, ensure_ascii=False)
|
||
ticket.execute_time = datetime.now(timezone.utc)
|
||
await db.commit()
|
||
return
|
||
|
||
instance.password = decrypt_password(instance.password)
|
||
executor = TicketExecutor()
|
||
report = await executor.execute(ticket, instance)
|
||
|
||
ticket.status = "success" if report.status == "success" else "failed"
|
||
ticket.execute_result = report.to_json()
|
||
ticket.execute_time = datetime.now(timezone.utc)
|
||
await db.commit()
|
||
|
||
logger.info("定时工单 %d 执行完成: %s", ticket_id, report.status)
|
||
|
||
except Exception as e:
|
||
logger.exception("定时工单 %d 执行异常", ticket_id)
|
||
try:
|
||
result = await db.execute(select(TicketModel).where(TicketModel.id == ticket_id))
|
||
ticket = result.scalar_one_or_none()
|
||
if ticket:
|
||
ticket.status = "failed"
|
||
ticket.execute_result = json.dumps({"status": "failed", "errors": [f"{type(e).__name__}: {e}"]}, ensure_ascii=False)
|
||
ticket.execute_time = datetime.now(timezone.utc)
|
||
await db.commit()
|
||
except Exception:
|
||
logger.exception("更新定时工单 %d 失败状态也失败", ticket_id)
|
||
|
||
|
||
def cancel_ticket_execution(ticket_id: int) -> bool:
|
||
"""取消定时执行任务。
|
||
|
||
Args:
|
||
ticket_id: 工单 ID
|
||
|
||
Returns:
|
||
是否成功取消
|
||
"""
|
||
scheduler = _get_scheduler()
|
||
job_id = f"ticket_{ticket_id}"
|
||
try:
|
||
scheduler.remove_job(job_id)
|
||
logger.info("已取消工单 %d 的定时执行", ticket_id)
|
||
return True
|
||
except Exception:
|
||
return False
|
||
|
||
|
||
async def recover_scheduled_tickets() -> int:
|
||
"""启动时恢复所有 scheduled 状态的工单。
|
||
|
||
扫描 DB 中 status=scheduled 的工单,重新注册到 APScheduler。
|
||
|
||
Returns:
|
||
恢复的任务数量
|
||
"""
|
||
from app.core.database import async_session
|
||
from app.models.ticket import Ticket as TicketModel
|
||
from sqlalchemy import select
|
||
|
||
count = 0
|
||
async with async_session() as db:
|
||
result = await db.execute(
|
||
select(TicketModel).where(TicketModel.status == "scheduled")
|
||
)
|
||
tickets = result.scalars().all()
|
||
|
||
now = datetime.now(timezone.utc)
|
||
for ticket in tickets:
|
||
if not ticket.scheduled_at:
|
||
continue
|
||
|
||
scheduled = ticket.scheduled_at
|
||
if scheduled.tzinfo is None:
|
||
scheduled = scheduled.replace(tzinfo=timezone.utc)
|
||
|
||
if scheduled <= now:
|
||
# 已过期,立即执行
|
||
logger.info("恢复过期定时工单 %d,立即执行", ticket.id)
|
||
asyncio.create_task(_execute_scheduled_ticket(ticket.id))
|
||
else:
|
||
await schedule_ticket_execution(ticket.id, scheduled)
|
||
|
||
count += 1
|
||
|
||
if count > 0:
|
||
logger.info("恢复了 %d 个定时执行工单", count)
|
||
return count
|
||
|
||
|
||
def get_active_scheduled_count() -> int:
|
||
"""返回当前 APScheduler 中的定时任务数量。"""
|
||
scheduler = _get_scheduler()
|
||
if not scheduler.running:
|
||
return 0
|
||
jobs = scheduler.get_jobs()
|
||
return len([j for j in jobs if j.id.startswith("ticket_")])
|
||
|
||
|
||
def get_active_scheduled_ids() -> list[int]:
|
||
"""返回当前定时工单 ID 列表。"""
|
||
scheduler = _get_scheduler()
|
||
if not scheduler.running:
|
||
return []
|
||
jobs = scheduler.get_jobs()
|
||
ids = []
|
||
for j in jobs:
|
||
if j.id.startswith("ticket_"):
|
||
try:
|
||
ids.append(int(j.id.split("_")[1]))
|
||
except (ValueError, IndexError):
|
||
pass
|
||
return ids
|