146 lines
4.8 KiB
Python
146 lines
4.8 KiB
Python
"""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.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_log_cleanup() -> None:
|
||
"""每 24 小时清理一次过期访问日志(在线程池执行同步 DB 操作)。"""
|
||
import asyncio
|
||
|
||
while True:
|
||
await asyncio.sleep(24 * 3600)
|
||
try:
|
||
await asyncio.to_thread(_cleanup_access_logs)
|
||
except Exception: # noqa: BLE001
|
||
logger.exception("定期清理访问日志失败")
|
||
|
||
|
||
@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_log_cleanup())
|
||
try:
|
||
await asyncio.wait_for(asyncio.to_thread(_cleanup_access_logs), 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(public_router, tags=["public"])
|
||
|
||
return app
|
||
|
||
|
||
app = create_app() |