Files
amb_rag/backend/app/main.py
T
2026-09-02 18:04:41 +08:00

146 lines
4.8 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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()