Files
opsxsql/backend/app/services/ticket_scheduler.py
T
logan 00db0e5bca feat: 日志自动清理 — 系统设置+定时任务+手动操作
1. 系统设置新增 cleanup 分组(6项配置):
   - 启用自动清理(开关)
   - 清理执行时间(默认凌晨3点)
   - 工单保留天数(默认90天)
   - 审计日志保留天数(默认180天)
   - 查询历史保留天数(默认30天)
   - 定时任务日志保留天数(默认30天)

2. log_cleanup.py: 自动清理服务
   - 只清理已完成/已拒绝的工单
   - 按保留天数过滤
   - 0天=不清理

3. APScheduler cron job: 每天03:00自动清理

4. API endpoints:
   - POST /settings/cleanup/run — 手动触发清理
   - GET /settings/cleanup/preview — 预览待清理数量

5. 前端 PlatformConfigPage:
   - GROUP_META 新增 cleanup 分组
   - CleanupActions 子组件: 预览+手动清理按钮

6. 清空所有历史工单数据(tickets/ticket_approvals/audit_logs/query_histories)
2026-05-25 00:19:38 +08:00

260 lines
8.6 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""工单定时执行调度器 — 基于 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 持久化)")
# 注册日志自动清理 cron job(每天凌晨执行)
try:
scheduler.remove_job('log_cleanup')
except Exception:
pass
from app.services.log_cleanup import run_cleanup_sync
scheduler.add_job(
run_cleanup_sync,
'cron',
hour=3,
minute=0,
id='log_cleanup',
replace_existing=True,
name='日志自动清理',
)
logger.info("日志清理 cron job 已注册 (每天 03:00)")
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