"""AI Knowledge Link — FastAPI 应用工厂。 Phase 1-3: 配置校验、日志、异常处理器、健康检查、lifespan 资源管理、认证路由。 后续 Phase 逐步挂载:knowledge-bases、documents、public /k/ 路由。 """ from contextlib import asynccontextmanager from pathlib import Path from fastapi import FastAPI from fastapi.middleware.cors import CORSMiddleware from app.api.auth import router as auth_router from app.api.categories import router as cat_router from app.api.documents import router as doc_router from app.api.health import router as health_router from app.api.knowledge_bases import router as kb_router from app.api.recycle_bin import router as recycle_router from app.public.routes import router as public_router from app.core.config import get_settings from app.core.db import dispose_engine, get_session_factory from app.core.errors import LinkExpiredError, register_exception_handlers from app.core.logging import get_logger, setup_logging logger = get_logger(__name__) def _seed_free_plan() -> None: """启动时确保 free plan 存在(幂等)。""" from app.repositories.plan_repo import PlanRepository factory = get_session_factory() with factory() as session: repo = PlanRepository(session) plan = repo.get_by_code("free") if plan is None: repo.get_or_create_free() session.commit() logger.info("Seeded free plan (100MB / 20MB-per-file)") else: logger.debug("Free plan already exists (id=%s)", plan.id) def _cleanup_access_logs() -> int: """清理超过保留期的访问日志。返回删除条数。""" from datetime import datetime, timedelta from sqlalchemy import delete from app.core.config import get_settings from app.models.access_log import AccessLog settings = get_settings() retention = settings.access_log_retention_days if retention <= 0: return 0 cutoff = (datetime.now() - timedelta(days=retention)).strftime("%Y-%m-%d %H:%M:%S") factory = get_session_factory() with factory() as session: result = session.execute(delete(AccessLog).where(AccessLog.accessed_at < cutoff)) session.commit() count = result.rowcount or 0 if count: logger.info("已清理 %d 条过期访问日志(保留 %d 天)", count, retention) return count async def _periodic_cleanup() -> None: """每小时执行一次数据清理:访问日志 + 回收站过期内容。""" import asyncio while True: await asyncio.sleep(3600) try: await asyncio.to_thread(_cleanup_access_logs) except Exception: # noqa: BLE001 logger.exception("定期清理访问日志失败") try: await asyncio.to_thread(_purge_recycle_bin) except Exception: # noqa: BLE001 logger.exception("定期清理回收站失败") def _purge_recycle_bin() -> int: """彻底删除回收站中超过保留期的内容。""" from app.services.recycle_bin_service import RecycleBinService factory = get_session_factory() with factory() as session: return RecycleBinService(session).purge_expired() @asynccontextmanager async def lifespan(app: FastAPI): settings = get_settings() setup_logging() # 确保 data 目录存在 data_dir = settings.storage_root_path data_dir.mkdir(parents=True, exist_ok=True) logger.info("Starting backend (env=%s, data=%s)", settings.environment, data_dir) # Seed:确保 free plan 存在 _seed_free_plan() # 启动时清理过期数据:访问日志 + 回收站(不阻塞启动) import asyncio cleanup_task = asyncio.create_task(_periodic_cleanup()) try: await asyncio.wait_for(asyncio.to_thread(_cleanup_access_logs), timeout=15) except Exception: # noqa: BLE001 logger.warning("启动时清理访问日志未完成(首次部署属正常)") try: await asyncio.wait_for(asyncio.to_thread(_purge_recycle_bin), timeout=15) except Exception: # noqa: BLE001 logger.warning("启动时清理回收站未完成(首次部署属正常)") yield cleanup_task.cancel() dispose_engine() logger.info("Backend shutdown complete") def create_app() -> FastAPI: settings = get_settings() app = FastAPI( title="AI Knowledge Link", version="0.1.0", lifespan=lifespan, docs_url="/api/docs" if not settings.is_production else None, openapi_url="/api/openapi.json" if not settings.is_production else None, ) # CORS(开发期允许 Vite dev server) app.add_middleware( CORSMiddleware, allow_origins=[settings.frontend_origin], allow_credentials=True, allow_methods=["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"], allow_headers=["Content-Type", "X-Requested-With"], ) register_exception_handlers(app) # 链接过期:HTML 返回友好失效页,JSON/MD/TXT 返回对应格式(HTTP 410) from app.public.routes import link_expired_handler app.add_exception_handler(LinkExpiredError, link_expired_handler) # 路由挂载 app.include_router(health_router, prefix="/api", tags=["health"]) app.include_router(auth_router, prefix="/api", tags=["auth"]) app.include_router(kb_router, prefix="/api", tags=["knowledge-bases"]) app.include_router(doc_router, prefix="/api", tags=["documents"]) app.include_router(cat_router, prefix="/api", tags=["categories"]) app.include_router(recycle_router, prefix="/api", tags=["recycle-bin"]) app.include_router(public_router, tags=["public"]) return app app = create_app()