后端新增 4 个 LDAP 管理端点: - POST /ldap-config/create-ou: 创建组织单元 - POST /ldap-config/create-user: 创建单个用户 (SSHA加密) - POST /ldap-config/delete-user: 删除用户 - POST /ldap-config/batch-import-ldap: 批量导入用户 - POST /ldap-config/user-list: 查询用户列表(恢复) 前端 LdapConfigPage 增强: - LDAP 账号管理折叠区(配置存在即显示) - OU 下拉选择 + 根节点选项 + 数量统计 - 用户列表表格(全选/单选/DN显示) - 新建 OU 弹框(名称+父级DN+描述) - 添加用户弹框(用户名+密码+显示名+邮箱+目标OU) - 删除选中用户按钮(确认后批量删除) 前端 UsersPage BatchImportModal 三 Tab: - 本地导入 / 导入到 LDAP / 从 LDAP 导入 测试修复: - test_user_endpoint.py: 分页格式兼容 - test_users.py: 分页格式兼容 - test_system_settings.py: 精简后配置项匹配
261 lines
8.9 KiB
Python
261 lines
8.9 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 持久化)")
|
||
|
||
# 注册日志自动清理 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:
|
||
"""执行定时工单 — 状态切换后 create_task 后台执行,不阻塞 APScheduler。"""
|
||
import asyncio
|
||
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)
|
||
|
||
# 先切状态为 executing
|
||
async with async_session() as db:
|
||
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()
|
||
|
||
# 后台 task 执行,APScheduler 立即释放
|
||
async def _do_exec():
|
||
async with async_session() as db:
|
||
try:
|
||
t = (await db.execute(select(TicketModel).where(TicketModel.id == ticket_id))).scalar_one_or_none()
|
||
if not t or t.status != "executing":
|
||
return
|
||
inst = (await db.execute(select(Instance).where(Instance.id == t.instance_id))).scalar_one_or_none()
|
||
if not inst:
|
||
t.status = "failed"
|
||
t.execute_result = json.dumps({"status": "failed", "errors": ["数据源实例不存在"]}, ensure_ascii=False)
|
||
t.execute_time = datetime.now(timezone.utc)
|
||
await db.commit()
|
||
return
|
||
inst.password = decrypt_password(inst.password)
|
||
executor = TicketExecutor()
|
||
report = await executor.execute(t, inst)
|
||
t.status = "success" if report.status == "success" else "failed"
|
||
t.execute_result = report.to_json()
|
||
t.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:
|
||
t = (await db.execute(select(TicketModel).where(TicketModel.id == ticket_id))).scalar_one_or_none()
|
||
if t:
|
||
t.status = "failed"
|
||
t.execute_result = json.dumps({"status": "failed", "errors": [f"{type(e).__name__}: {e}"]}, ensure_ascii=False)
|
||
t.execute_time = datetime.now(timezone.utc)
|
||
await db.commit()
|
||
except Exception:
|
||
logger.exception("更新定时工单 %d 失败状态也失败", ticket_id)
|
||
|
||
asyncio.get_event_loop().create_task(_do_exec())
|
||
|
||
|
||
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
|