# ============================================================================= # 企微IT智能服务台 — 复杂场景重构 暂停超时清理定时任务 # ============================================================================= # 说明:后台定时任务,扫描 paused 状态且超过 24 小时未恢复的会话, # 自动标记为 closed(closed_by = "system(timeout)"), # 并清理 Redis 恢复点与暂停会话集合,推送超时关闭 WS 事件。 # # 调用方式: # 1. FastAPI lifespan 中 asyncio.create_task(TimeoutCleaner(...).run_scheduled()) # 2. 或外部调度器定期调用 run_once() # ============================================================================= from __future__ import annotations import asyncio import logging from datetime import datetime, timedelta, timezone from typing import Any from sqlalchemy import select from app.constants import ( PAUSE_TIMEOUT_HOURS, REDIS_KEY_PAUSED_SESSIONS, REDIS_KEY_RESUME_POINT, ) from app.models.automation import AutoSession from app.services.automation.progress_publisher import publish_timeout_closed logger = logging.getLogger(__name__) class TimeoutCleaner: """暂停超时清理器。 扫描 paused 状态的会话,超过 PAUSE_TIMEOUT_HOURS(默认 24 小时) 未恢复的会话自动关闭,并推送 WS 通知。 """ def __init__(self, db_factory: Any, redis: Any = None): """初始化。 Args: db_factory: 异步 DB 会话工厂(如 app.database._get_session_factory()) redis: Redis 客户端(可选,用于清理恢复点) """ self.db_factory = db_factory self.redis = redis async def run_once(self) -> int: """执行一次扫描,返回关闭的会话数。 Returns: int: 本次扫描关闭的会话数量 """ closed_count = 0 async with self.db_factory() as db: # 查询超时的 paused 会话(paused_at < cutoff 隐含 NOT NULL) cutoff = datetime.now(timezone.utc) - timedelta(hours=PAUSE_TIMEOUT_HOURS) stmt = select(AutoSession).where( AutoSession.status == "paused", AutoSession.paused_at < cutoff, ) sessions = list((await db.execute(stmt)).scalars().all()) if not sessions: return 0 for session in sessions: try: # 标记关闭 session.status = "closed" session.closed_by = "system(timeout)" closed_at = datetime.now(timezone.utc) # 清理 Redis 恢复点 if self.redis: await self._cleanup_redis(session.id, session.employee_id) await db.flush() closed_count += 1 # 推送 WS 超时关闭事件 await publish_timeout_closed( session_id=session.id, closed_at=closed_at.isoformat(), reason=f"暂停超过 {PAUSE_TIMEOUT_HOURS} 小时未恢复,已自动关闭", ) logger.info( f"超时关闭会话: session={session.id} " f"paused_at={session.paused_at} closed_at={closed_at}" ) except Exception as e: # noqa: BLE001 logger.error(f"超时关闭会话失败 session={session.id}: {e}") await db.commit() if closed_count > 0: logger.info(f"超时清理完成: 共关闭 {closed_count} 个暂停会话") return closed_count async def run_scheduled(self, interval: int = 3600) -> None: """定时扫描(每小时一次)。 Args: interval: 扫描间隔(秒),默认 3600 = 1 小时 """ logger.info(f"启动暂停超时清理定时任务,间隔 {interval} 秒") while True: try: await self.run_once() except Exception as e: # noqa: BLE001 logger.error(f"超时清理定时任务异常: {e}") await asyncio.sleep(interval) async def _cleanup_redis(self, session_id: str, employee_id: str) -> None: """清理 Redis 中的恢复点和暂停会话集合。""" if not self.redis: return try: # 删除恢复点 resume_key = REDIS_KEY_RESUME_POINT.format(session_id=session_id) await self.redis.delete(resume_key) # 从暂停会话集合中移除 paused_key = REDIS_KEY_PAUSED_SESSIONS.format(employee_id=employee_id) await self.redis.srem(paused_key, session_id) except Exception as e: # noqa: BLE001 logger.warning(f"Redis 清理失败 session={session_id}: {e}")