"""Точка входа FastAPI приложения — Академический помощник.""" import logging from collections.abc import AsyncGenerator from contextlib import asynccontextmanager from fastapi import Depends, FastAPI, WebSocket, WebSocketDisconnect from fastapi.middleware.cors import CORSMiddleware from fastapi.responses import JSONResponse from prometheus_fastapi_instrumentator import Instrumentator from app.api import admin, auth, documents, search, tasks from app.config import settings from app.core.minio_client import ensure_bucket from app.core.redis_client import close_pool from app.core.security import get_ws_user from app.core.websocket_manager import ws_manager from app.database import engine logger = logging.getLogger(__name__) logging.basicConfig( level=logging.INFO, format="%(asctime)s %(levelname)s %(name)s: %(message)s", ) @asynccontextmanager async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]: """Инициализация при старте, очистка при остановке.""" logger.info("Запуск Академического помощника...") # Проверяем PostgreSQL try: import sqlalchemy async with engine.connect() as conn: await conn.execute(sqlalchemy.text("SELECT 1")) logger.info("PostgreSQL: OK") except Exception as e: logger.error(f"PostgreSQL: {e}") # Инициализируем MinIO бакеты (один раз при старте, не на каждый запрос) try: ensure_bucket(settings.MINIO_BUCKET_DOCS) ensure_bucket(settings.MINIO_BUCKET_BACKUPS) logger.info("MinIO: бакеты готовы") except Exception as e: logger.error(f"MinIO: {e}") yield logger.info("Остановка приложения...") await engine.dispose() await close_pool() app = FastAPI( title="Академический помощник API", version="0.1.0", lifespan=lifespan, docs_url="/api/docs" if settings.ENVIRONMENT == "development" else None, redoc_url=None, openapi_url="/api/openapi.json" if settings.ENVIRONMENT == "development" else None, ) # ─── CORS ───────────────────────────────────────────────────────────────────── # allow_credentials=True несовместим с allow_origins=["*"]. # Указываем явные origins. Wildcard заголовки/методы запрещены с credentials. app.add_middleware( CORSMiddleware, allow_origins=settings.CORS_ORIGINS, allow_credentials=True, allow_methods=["GET", "POST", "DELETE", "OPTIONS"], allow_headers=["Authorization", "Content-Type", "Accept"], ) # ─── Роутеры ────────────────────────────────────────────────────────────────── app.include_router(auth.router, prefix="/api") app.include_router(tasks.router, prefix="/api") app.include_router(search.router, prefix="/api") app.include_router(documents.router, prefix="/api") app.include_router(admin.router, prefix="/api") # ─── Метрики Prometheus ─────────────────────────────────────────────────────── # HTTP-метрики (кол-во/латентность запросов по хендлерам) на /metrics. # Prometheus скрейпит их (см. infra/prometheus/prometheus.yml, профиль observability). Instrumentator().instrument(app).expose(app, endpoint="/metrics", include_in_schema=False) # ─── WebSocket ──────────────────────────────────────────────────────────────── @app.websocket("/ws/tasks/{public_id}") async def websocket_task_updates( websocket: WebSocket, public_id: str, # Аутентификация через ?token=JWT (браузеры не могут передавать Authorization header в WS) current_user=Depends(get_ws_user), ) -> None: """ Подписка на обновления задачи в реальном времени. Подключение: ws://host/ws/tasks/{public_id}?token= Пользователь получает только обновления СВОИХ задач — ownership проверяется до установки соединения. """ from sqlalchemy import select from app.database import AsyncSessionLocal from app.models.task import Task # Проверяем что задача принадлежит этому пользователю async with AsyncSessionLocal() as db: result = await db.execute( select(Task).where( Task.public_id == public_id, Task.user_id == current_user.id, ) ) task = result.scalar_one_or_none() if task is None: await websocket.close(code=4004, reason="Task not found or access denied") return await ws_manager.connect(public_id, websocket) try: while True: data = await websocket.receive_text() if data == "ping": await websocket.send_text("pong") except WebSocketDisconnect: ws_manager.disconnect(public_id, websocket) # ─── Health ──────────────────────────────────────────────────────────────────── @app.get("/health", tags=["monitoring"]) async def health_check() -> JSONResponse: return JSONResponse({"status": "ok", "version": "0.1.0"}) @app.get("/", include_in_schema=False) async def root() -> JSONResponse: return JSONResponse({"message": "Академический помощник API"})