"""FastAPI application entry point.""" from __future__ import annotations import asyncio import json import logging import queue as thread_queue import shutil import threading import uuid from datetime import datetime from pathlib import Path from typing import Optional from fastapi import FastAPI, File, HTTPException, UploadFile from fastapi.middleware.cors import CORSMiddleware from fastapi.responses import FileResponse, StreamingResponse from fastapi.staticfiles import StaticFiles from backend.app import pipeline # --------------------------------------------------------------------------- # # Logging + SSE broadcast # # --------------------------------------------------------------------------- # logging.basicConfig( level=logging.INFO, format="%(asctime)s %(levelname)s [%(name)s] %(message)s", ) logger = logging.getLogger(__name__) _log_buffer: list[dict] = [] # 最近 200 条,供新连接回放 _log_queues: list[thread_queue.Queue] = [] _log_lock = threading.Lock() class _BroadcastHandler(logging.Handler): """把日志记录广播给所有 SSE 客户端。""" def emit(self, record: logging.LogRecord) -> None: entry = { "time": datetime.fromtimestamp(record.created).strftime("%H:%M:%S"), "level": record.levelname, "name": record.name.replace("backend.app.", ""), "msg": record.getMessage(), } with _log_lock: _log_buffer.append(entry) if len(_log_buffer) > 200: _log_buffer.pop(0) for q in _log_queues: try: q.put_nowait(entry) except thread_queue.Full: pass # 挂到根 logger,覆盖所有模块日志 _broadcast_handler = _BroadcastHandler() logging.getLogger().addHandler(_broadcast_handler) # --------------------------------------------------------------------------- # # Paths & constants # # --------------------------------------------------------------------------- # _ROOT = Path(__file__).resolve().parents[2] UPLOADS_DIR = _ROOT / "data" / "uploads" OUTPUTS_DIR = _ROOT / "data" / "outputs" _DEFAULT_AI_NAME = "【2026-04-09】端午 - 背标 - 天问.ai" _DEFAULT_WORD_NAME = "天问礼品粽【260331】.docx" _DEFAULT_AI = _ROOT / _DEFAULT_AI_NAME _DEFAULT_WORD = _ROOT / _DEFAULT_WORD_NAME ALLOWED_AI_EXT = {".ai", ".pdf"} ALLOWED_WORD_EXT = {".docx"} # --------------------------------------------------------------------------- # # App # # --------------------------------------------------------------------------- # app = FastAPI( title="诸老大包装审核 API", description="Upload an Illustrator file and a Word document to validate packaging copy.", version="2.0.0", ) app.add_middleware( CORSMiddleware, allow_origins=["*"], allow_methods=["*"], allow_headers=["*"], ) # --------------------------------------------------------------------------- # # Helpers # # --------------------------------------------------------------------------- # def _save_upload(upload: UploadFile, dest: Path) -> None: dest.parent.mkdir(parents=True, exist_ok=True) with dest.open("wb") as fh: fh.write(upload.file.read()) def _copy_default(src: Optional[Path], dest: Path, label: str) -> None: if src is None or not src.exists(): raise HTTPException( status_code=400, detail=f"未上传{label}且找不到默认样例文件,请上传文件后重试。", ) dest.parent.mkdir(parents=True, exist_ok=True) shutil.copy2(src, dest) # --------------------------------------------------------------------------- # # Endpoints # # --------------------------------------------------------------------------- # @app.get("/api/logs/stream") async def log_stream() -> StreamingResponse: """SSE 端点:实时推送后端日志给前端侧边栏。""" q: thread_queue.Queue = thread_queue.Queue(maxsize=500) with _log_lock: _log_queues.append(q) recent = list(_log_buffer) async def generate(): try: # 先把缓冲区里的历史日志推过去 for entry in recent: yield f"data: {json.dumps(entry, ensure_ascii=False)}\n\n" # 再持续推新日志 while True: batch: list[dict] = [] try: while True: batch.append(q.get_nowait()) except thread_queue.Empty: pass for entry in batch: yield f"data: {json.dumps(entry, ensure_ascii=False)}\n\n" if not batch: yield ": keepalive\n\n" await asyncio.sleep(0.25) finally: with _log_lock: if q in _log_queues: _log_queues.remove(q) return StreamingResponse( generate(), media_type="text/event-stream", headers={ "Cache-Control": "no-cache", "X-Accel-Buffering": "no", "Connection": "keep-alive", }, ) @app.post("/api/process") async def process_endpoint( ai_file: Optional[UploadFile] = File(None), word_file: Optional[UploadFile] = File(None), ) -> dict: """运行完整 pipeline:AI → PDF → MinerU → Word 校验。""" job_id = uuid.uuid4().hex upload_dir = UPLOADS_DIR / job_id output_dir = OUTPUTS_DIR / job_id logger.info("POST /api/process job_id=%s", job_id) # ── Resolve AI file ──────────────────────────────────────────────────── # if ai_file is not None: original_name = Path(ai_file.filename or "source.ai").name suffix = Path(original_name).suffix.lower() if suffix not in ALLOWED_AI_EXT: raise HTTPException( status_code=400, detail=f"不支持的 AI 文件格式 '{suffix}',请上传 .ai 或 PDF。", ) ai_path = upload_dir / original_name _save_upload(ai_file, ai_path) else: ai_path = upload_dir / (_DEFAULT_AI.name if _DEFAULT_AI else "source.ai") _copy_default(_DEFAULT_AI, ai_path, "AI 设计文件") # ── Resolve Word file ────────────────────────────────────────────────── # if word_file is not None: suffix = Path(word_file.filename or "").suffix.lower() if suffix not in ALLOWED_WORD_EXT: raise HTTPException( status_code=400, detail=f"不支持的 Word 文件格式 '{suffix}',请上传 .docx。", ) word_path = upload_dir / f"reference{suffix}" _save_upload(word_file, word_path) else: word_path = upload_dir / (_DEFAULT_WORD.name if _DEFAULT_WORD else "reference.docx") _copy_default(_DEFAULT_WORD, word_path, "Word 校对稿") # ── Run pipeline in thread pool(不阻塞事件循环,SSE 可正常推日志) ─── # try: loop = asyncio.get_event_loop() result = await loop.run_in_executor( None, pipeline.process_document, ai_path, word_path, output_dir, job_id, ) except FileNotFoundError as exc: logger.exception("Pipeline error (not found): job_id=%s", job_id) raise HTTPException(status_code=404, detail=str(exc)) from exc except RuntimeError as exc: logger.exception("Pipeline error (runtime): job_id=%s", job_id) raise HTTPException(status_code=422, detail=str(exc)) from exc except Exception as exc: logger.exception("Pipeline error (unexpected): job_id=%s", job_id) raise HTTPException(status_code=500, detail=f"处理失败:{exc}") from exc return result @app.get("/api/files/{job_id}/{file_path:path}") async def serve_file(job_id: str, file_path: str) -> FileResponse: """提供 job 产物文件(预览 PDF、JSON 等)。""" target = OUTPUTS_DIR / job_id / file_path if not target.exists() or not target.is_file(): raise HTTPException(status_code=404, detail="文件不存在") suffix = target.suffix.lower() media_type = { ".pdf": "application/pdf", ".json": "application/json", ".md": "text/markdown", }.get(suffix, "application/octet-stream") return FileResponse(target, media_type=media_type) @app.get("/api/health") async def health() -> dict: return {"status": "ok"} # 生产镜像:Vite 构建产物与 API 同源,无需配置 VITE_API_BASE_URL _dist = _ROOT / "frontend" / "dist" if _dist.is_dir(): app.mount("/", StaticFiles(directory=str(_dist), html=True), name="frontend")