Files
opsxsql/backend/app/services/ticket_scheduler.py
T
logan be2d6f3894 feat: LDAP 账号管理 — OU管理/添加用户/删除用户/批量导入
后端新增 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: 精简后配置项匹配
2026-05-25 17:34:53 +08:00

261 lines
8.9 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:
"""执行定时工单 — 状态切换后 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