"""main.py —— BFF 应用入口(ARCHITECTURE.md §2/§3)。 - 挂全部 5 个 router(qa/kg/extraction 为 A2/A3 实现,import 路径不变)。 - CORS:允许 http://localhost:5180 与 http://127.0.0.1:5180(Vite dev)。 - lifespan:init_db(幂等迁移);关闭回收 httpx 客户端与 Neo4j 连接。 - 全局 RequestValidationError → VALIDATION 信封(API.md §1,2026-08-25 PM 集成)。 - docgen(P2):routers/docgen.py 对 services/docgen/*(dev1 归属)按契约 import, 模块缺失时自动回落到内置最小接口桩(生成/编辑返回 503),不阻断 BFF 启动。 - 生产形态(M6):ui/dist 存在时静态托管(ARCHITECTURE.md §3)。 """ from __future__ import annotations import logging import time from contextlib import asynccontextmanager from pathlib import Path from fastapi import Depends, FastAPI, Request from fastapi.exceptions import RequestValidationError from fastapi.middleware.cors import CORSMiddleware from fastapi.responses import JSONResponse from fastapi.staticfiles import StaticFiles from .config import get_settings from .db import close_db, init_db from .routers import capabilities, docgen, extraction, figures, health, kg, legacy, qa, sessions from .routers import bioflow_proxy from .routers import paper from .routers import private_docs from .routers import review_proxy from .routers import admin from .routers import auth from . import logwriter from .auth import AuthError, get_current_user, require_authorization from .services.auth import seed_admin from .services.settings import get_hub_settings from .upstream import close_client logging.basicConfig(level=logging.INFO) _ALLOWED_ORIGINS = [ "http://localhost:5180", "http://127.0.0.1:5180", ] def create_app() -> FastAPI: settings = get_settings() app = FastAPI(title=settings.app_name, version=settings.version) app.add_middleware( CORSMiddleware, allow_origins=_ALLOWED_ORIGINS, allow_credentials=True, allow_methods=["*"], allow_headers=["*"], ) # 行为日志请求中间件:自动采集所有 /api/* 请求(user_id 待鉴权上线后补) @app.middleware("http") async def _log_requests(request: Request, call_next): start = time.monotonic() response = await call_next(request) if request.url.path.startswith("/api/"): user = getattr(request.state, "user", None) logwriter.log_event( "http_request", user_id=user["id"] if user else None, method=request.method, path=request.url.path, status=response.status_code, latency_ms=round((time.monotonic() - start) * 1000, 1), ) return response # 鉴权异常 → 统一错误信封 @app.exception_handler(AuthError) async def _auth_error_handler(_: Request, exc: AuthError) -> JSONResponse: return JSONResponse( status_code=exc.status, content={"ok": False, "error": {"code": exc.code, "message": exc.message, "degraded": False}}, ) app.include_router(health.router) app.include_router(auth.router) # /api/auth/*(login/status 免鉴权,me/logout 需登录) app.include_router(sessions.router, dependencies=[Depends(get_current_user)]) app.include_router(qa.router, dependencies=[Depends(require_authorization("qa"))]) app.include_router(capabilities.router, dependencies=[Depends(get_current_user)]) app.include_router(kg.router, dependencies=[Depends(require_authorization("kg"))]) app.include_router(extraction.router, dependencies=[Depends(require_authorization("extraction"))]) app.include_router(figures.router, dependencies=[Depends(require_authorization("figures"))]) app.include_router(legacy.router, dependencies=[Depends(require_authorization("legacy"))]) app.include_router(docgen.router, dependencies=[Depends(require_authorization("docgen"))]) # docgen(P2) app.include_router(private_docs.router, dependencies=[Depends(require_authorization("private_docs"))]) # 私有资料问答(FR-PRIV-004) if get_settings().bioflow_enabled: app.include_router(bioflow_proxy.router, dependencies=[Depends(require_authorization("bioflow"))]) # 生信反代(BIOFLOW_ENABLED 开关,FR-BIO-028) if get_settings().paper_writer_enabled: app.include_router(paper.router, dependencies=[Depends(require_authorization("paper"))]) # 论文撰写(PAPER_WRITER_ENABLED 开关) if get_settings().review_enabled: app.include_router(review_proxy.router, dependencies=[Depends(get_current_user)]) # 科研审稿反代(REVIEW_ENABLED 开关) app.include_router(admin.router) # 管理后台(/api/admin/*) @app.exception_handler(RequestValidationError) async def _validation_handler(_: Request, exc: RequestValidationError) -> JSONResponse: # API.md §1 已裁决:参数校验错误收敛为统一 VALIDATION 信封(422) return JSONResponse( status_code=422, content={ "ok": False, "error": {"code": "VALIDATION", "message": "请求参数不合法", "degraded": False}, }, ) @asynccontextmanager async def _lifespan(_app: FastAPI): _db = await init_db() await seed_admin(_db) hub = await get_hub_settings(_db) logwriter.init_writer(settings.log_dir, settings.log_retention_days, hub_id=hub["hub_id"]) await logwriter.start_writer() try: yield finally: await logwriter.stop_writer() await close_client() try: from .services.neo4j import close_neo4j # A3 提供;缺失则跳过 await close_neo4j() except ImportError: pass await close_db() app.router.lifespan_context = _lifespan # 生产形态:静态托管 ui/dist(存在时挂载;本地开发期可无)。 # 2026-08-25 PM 修复:dist 路径必须基于项目根解析(BFF 工作目录是 sdk/, # 相对路径会解析成 sdk/ui/dist);sync.sh 排除 dist,需单独同步。 project_root = Path(__file__).resolve().parent.parent.parent # 管理后台静态托管(/admin;须在 "/" 之前挂载) admin_dist = project_root / settings.admin_dist_dir if not Path(settings.admin_dist_dir).is_absolute() else Path(settings.admin_dist_dir) if admin_dist.is_dir(): app.mount("/admin", StaticFiles(directory=str(admin_dist), html=True), name="admin") dist = project_root / settings.frontend_dist_dir if not Path(settings.frontend_dist_dir).is_absolute() else Path(settings.frontend_dist_dir) if dist.is_dir(): app.mount("/", StaticFiles(directory=str(dist), html=True), name="frontend") return app app = create_app()