"""PPT 解析后台任务。""" import asyncio import json import logging import threading from datetime import datetime from insurance.db.compat import db from insurance.models.ppt_session import PptSession logger = logging.getLogger(__name__) _local_locks: set[str] = set() _local_locks_guard = threading.Lock() _redis_locks: set[str] = set() STALE_TASK_TIMEOUT = 600 # 10 分钟 def recover_stale_tasks(): """启动时将长时间 parsing 的会话标记为 failed。""" from datetime import timedelta cutoff = datetime.now() - timedelta(seconds=STALE_TASK_TIMEOUT) stale = PptSession.query.filter( PptSession.status.in_(["parsing"]), PptSession.created_at < cutoff, ).all() for session in stale: session.status = "error" session.parse_error = "任务因服务重启而中断,请重新处理" session.parse_finished_at = datetime.now() logger.warning(f"恢复过期 PPT 解析任务: session_id={session.id}") if stale: db.session.commit() logger.info(f"已恢复 {len(stale)} 个过期 PPT 解析任务") def start_parse_task(app, session_id: str, user_id: str) -> bool: """启动后台解析任务,返回是否新启动。""" if not _acquire_task_lock(session_id): return False thread = threading.Thread( target=_run_parse_task, args=(app, session_id, user_id), daemon=True, ) thread.start() return True def _run_parse_task(app, session_id: str, user_id: str): with app.app_context(): try: _execute_parse(session_id, user_id) except Exception as exc: logger.error(f"PPT 解析后台任务失败 [{session_id}]: {exc}", exc_info=True) _mark_session_failed(session_id, str(exc)) finally: _release_task_lock(session_id) db.session.remove() def _execute_parse(session_id: str, user_id: str): from insurance.ppt.extraction import ExtractionOrchestrator session = PptSession.query.filter_by(id=session_id, user_id=user_id).first() if not session: return files = json.loads(session.files_json) if session.files_json else [] if not files: _mark_session_failed(session_id, "没有可处理的 PDF 文件") return orchestrator = ExtractionOrchestrator() extractions = [] total = len(files) session.status = "parsing" session.parse_progress = 0 session.parse_message = "数据结构化任务已启动" session.parse_error = None session.parse_started_at = datetime.now() session.parse_finished_at = None session.extractions_json = json.dumps([], ensure_ascii=False) db.session.commit() for index, file_info in enumerate(files, start=1): filename = file_info.get("name", "") filepath = file_info.get("path", "") plan_type = file_info.get("type", "savings") _update_progress( session_id, progress=_progress(index - 1, total), message=f"正在处理 {filename or f'第 {index} 个文件'}", extractions=extractions, ) try: result = asyncio.run(orchestrator.extract_plan(filepath, plan_type, force_reparse=True)) extractions.append(_build_extraction(file_info, filepath, result)) except Exception as exc: logger.error(f"PDF 解析失败 [{filename}]: {exc}", exc_info=True) extractions.append({ "pdfName": filename, "pdfPath": filepath, "planType": plan_type, "status": "error", "productName": "unknown", "data": None, "error": str(exc), "yearCount": 0, }) _update_progress( session_id, progress=_progress(index, total), message=f"已完成 {index}/{total} 个文件", extractions=extractions, ) session = PptSession.query.filter_by(id=session_id, user_id=user_id).first() if not session: return all_failed = all(e.get("status") == "error" for e in extractions) partial_count = sum(1 for e in extractions if e.get("status") == "partial") session.extractions_json = json.dumps(extractions, ensure_ascii=False) session.status = "error" if all_failed else "parsed" session.parse_progress = 100 if all_failed: session.parse_message = "处理失败" elif partial_count: session.parse_message = f"处理完成,{partial_count} 个文件需补充数据" else: session.parse_message = "处理完成" session.parse_error = "所有文件均处理失败" if all_failed else None session.parse_finished_at = datetime.now() db.session.commit() def _build_extraction(file_info: dict, filepath: str, result) -> dict: return { "pdfName": file_info.get("name", ""), "pdfPath": filepath, "planType": result.plan_type, "status": result.status, "productName": result.product_name, "data": result.data, "error": result.error, "yearCount": len(result.data.get("benefit_illustration", [])) if result.data else 0, } def _update_progress(session_id: str, progress: int, message: str, extractions: list[dict]): session = PptSession.query.filter_by(id=session_id).first() if not session: return session.status = "parsing" session.parse_progress = progress session.parse_message = message session.extractions_json = json.dumps(extractions, ensure_ascii=False) db.session.commit() def _mark_session_failed(session_id: str, error: str): session = PptSession.query.filter_by(id=session_id).first() if not session: return session.status = "error" session.parse_progress = 100 session.parse_message = "处理失败" session.parse_error = error[:1000] session.parse_finished_at = datetime.now() db.session.commit() def _progress(done: int, total: int) -> int: if total <= 0: return 0 return min(99, int(done / total * 100)) def _acquire_task_lock(session_id: str) -> bool: redis_key = f"ppt_parse_lock:{session_id}" try: from insurance.db.compat import redis_client if redis_client and redis_client.set(redis_key, "1", nx=True, ex=1800): _redis_locks.add(session_id) return True if redis_client: return False except Exception: pass with _local_locks_guard: if session_id in _local_locks: return False _local_locks.add(session_id) return True def _release_task_lock(session_id: str): if session_id in _redis_locks: try: from insurance.db.compat import redis_client if redis_client: redis_client.delete(f"ppt_parse_lock:{session_id}") except Exception: pass _redis_locks.discard(session_id) with _local_locks_guard: _local_locks.discard(session_id)