152 lines
6.8 KiB
Python
152 lines
6.8 KiB
Python
"""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()
|