feat: APScheduler持久化定时任务 + SQL检测按钮 + 定时时间醒目红色
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+恢复定时工单
This commit is contained in:
1 parent
ffb3d24e2a
commit
a8c92fe7ae
3 files changed
+208
-73
No files matched your search
@@ -77,8 +77,20 @@ async def lifespan(app: FastAPI):
|
||||
await init_db()
|
||||
await seed_data()
|
||||
await seed_workflows()
|
||||
|
||||
# 启动 APScheduler + 恢复定时工单
|
||||
from app.services.ticket_scheduler import start_scheduler, recover_scheduled_tickets, shutdown_scheduler
|
||||
start_scheduler()
|
||||
recovered = await recover_scheduled_tickets()
|
||||
if recovered > 0:
|
||||
import logging
|
||||
logging.getLogger(__name__).info("恢复了 %d 个定时工单", recovered)
|
||||
|
||||
yield
|
||||
|
||||
# Shutdown
|
||||
shutdown_scheduler()
|
||||
|
||||
|
||||
app = FastAPI(
|
||||
title="SQL-Platform API",
|
||||
|
||||
@@ -1,49 +1,97 @@
|
||||
"""工单定时执行调度器 — 基于 asyncio 的轻量级定时任务。"""
|
||||
"""工单定时执行调度器 — 基于 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 Dict
|
||||
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__)
|
||||
|
||||
# 内存中保存活跃的定时任务句柄
|
||||
_active_tasks: Dict[int, asyncio.Task] = {}
|
||||
# 全局调度器实例(延迟初始化)
|
||||
_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:
|
||||
"""注册一个定时执行任务。
|
||||
|
||||
使用 asyncio.create_task 创建后台等待任务,到达 scheduled_at 后自动执行工单。
|
||||
|
||||
Args:
|
||||
ticket_id: 工单 ID
|
||||
scheduled_at: 定时执行时间 (UTC)
|
||||
"""
|
||||
now = datetime.now(timezone.utc)
|
||||
delay = (scheduled_at - now).total_seconds()
|
||||
scheduler = _get_scheduler()
|
||||
job_id = f"ticket_{ticket_id}"
|
||||
|
||||
if delay <= 0:
|
||||
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)
|
||||
task = asyncio.create_task(_execute_scheduled_ticket(ticket_id))
|
||||
else:
|
||||
logger.info("工单 %d 定时执行: %s (%.1f 秒后)", ticket_id, scheduled_at, delay)
|
||||
task = asyncio.create_task(_wait_and_execute(ticket_id, delay))
|
||||
|
||||
_active_tasks[ticket_id] = task
|
||||
|
||||
|
||||
async def _wait_and_execute(ticket_id: int, delay: float) -> None:
|
||||
"""等待 delay 秒后执行工单。"""
|
||||
try:
|
||||
await asyncio.sleep(delay)
|
||||
await _execute_scheduled_ticket(ticket_id)
|
||||
except asyncio.CancelledError:
|
||||
logger.info("工单 %d 定时执行已取消", ticket_id)
|
||||
except Exception:
|
||||
logger.exception("工单 %d 定时执行异常", ticket_id)
|
||||
finally:
|
||||
_active_tasks.pop(ticket_id, None)
|
||||
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:
|
||||
@@ -52,7 +100,6 @@ async def _execute_scheduled_ticket(ticket_id: int) -> None:
|
||||
from app.models.ticket import Ticket as TicketModel
|
||||
from app.models.instance import Instance
|
||||
from app.services.ticket_executor import TicketExecutor
|
||||
from app.services.audit_log import AuditLogService
|
||||
from app.core.security import decrypt_password
|
||||
from sqlalchemy import select
|
||||
import json
|
||||
@@ -61,24 +108,20 @@ async def _execute_scheduled_ticket(ticket_id: int) -> None:
|
||||
|
||||
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
|
||||
|
||||
# 更新状态为 executing
|
||||
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:
|
||||
@@ -88,14 +131,10 @@ async def _execute_scheduled_ticket(ticket_id: int) -> None:
|
||||
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)
|
||||
@@ -115,8 +154,6 @@ async def _execute_scheduled_ticket(ticket_id: int) -> None:
|
||||
await db.commit()
|
||||
except Exception:
|
||||
logger.exception("更新定时工单 %d 失败状态也失败", ticket_id)
|
||||
finally:
|
||||
_active_tasks.pop(ticket_id, None)
|
||||
|
||||
|
||||
def cancel_ticket_execution(ticket_id: int) -> bool:
|
||||
@@ -128,20 +165,78 @@ def cancel_ticket_execution(ticket_id: int) -> bool:
|
||||
Returns:
|
||||
是否成功取消
|
||||
"""
|
||||
task = _active_tasks.get(ticket_id)
|
||||
if task and not task.done():
|
||||
task.cancel()
|
||||
_active_tasks.pop(ticket_id, None)
|
||||
scheduler = _get_scheduler()
|
||||
job_id = f"ticket_{ticket_id}"
|
||||
try:
|
||||
scheduler.remove_job(job_id)
|
||||
logger.info("已取消工单 %d 的定时执行", ticket_id)
|
||||
return True
|
||||
return False
|
||||
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:
|
||||
"""返回当前活跃的定时任务数量。"""
|
||||
return len(_active_tasks)
|
||||
"""返回当前 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 列表。"""
|
||||
return list(_active_tasks.keys())
|
||||
"""返回当前定时工单 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
|
||||
Reference in new issue
Block a user