221 lines
8.1 KiB
Python
221 lines
8.1 KiB
Python
"""
|
||
FastAPI 应用入口
|
||
挂载 v1 API 路由、CORS 中间件、生命周期管理
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import asyncio
|
||
import json
|
||
import logging
|
||
import threading
|
||
from contextlib import asynccontextmanager
|
||
from pathlib import Path
|
||
from typing import AsyncGenerator
|
||
|
||
from fastapi import FastAPI, Request
|
||
from fastapi.middleware.cors import CORSMiddleware
|
||
from fastapi.responses import FileResponse, JSONResponse, RedirectResponse, Response
|
||
|
||
from app.config import settings
|
||
from app.database import close_db, init_db
|
||
|
||
_logger = logging.getLogger(__name__)
|
||
|
||
|
||
class BotManager:
|
||
"""管理企业微信机器人的生命周期(在后台线程中运行)"""
|
||
|
||
def __init__(self):
|
||
self._thread: threading.Thread | None = None
|
||
self._running = False
|
||
|
||
def start(self):
|
||
"""在后台线程中启动机器人"""
|
||
if self._running:
|
||
_logger.info("企业微信机器人已在运行")
|
||
return
|
||
if not settings.WEWORK_BOT_ENABLED or not settings.WEWORK_BOT_ID or not settings.WEWORK_BOT_SECRET:
|
||
_logger.info("企业微信机器人未配置,跳过启动")
|
||
return
|
||
|
||
self._thread = threading.Thread(
|
||
target=self._run_bot, daemon=True, name="wework-bot"
|
||
)
|
||
self._thread.start()
|
||
_logger.info("企业微信机器人已在后台线程启动")
|
||
|
||
def _run_bot(self):
|
||
"""在线程中运行 bot(需要新建事件循环)"""
|
||
self._running = True
|
||
loop = asyncio.new_event_loop()
|
||
asyncio.set_event_loop(loop)
|
||
try:
|
||
loop.run_until_complete(self._run_bot_async())
|
||
except Exception as e:
|
||
_logger.error("企业微信机器人线程异常退出: %s", e, exc_info=True)
|
||
finally:
|
||
loop.close()
|
||
self._running = False
|
||
|
||
async def _run_bot_async(self):
|
||
"""异步运行 bot"""
|
||
from app.wework_bot import WeComBotService
|
||
service = WeComBotService()
|
||
await service.start_async()
|
||
|
||
async def restart(self) -> dict:
|
||
"""重启机器人(先停后启)"""
|
||
was_running = self._running
|
||
# 停止旧的(设置 enabled=False 让旧线程自然退出)
|
||
old_id = settings.WEWORK_BOT_ID
|
||
old_secret = settings.WEWORK_BOT_SECRET
|
||
old_enabled = settings.WEWORK_BOT_ENABLED
|
||
|
||
if not old_enabled or not old_id or not old_secret:
|
||
return {"success": False, "message": "请先配置机器人 ID 和 Secret 并启用"}
|
||
|
||
# 等待旧线程结束
|
||
if self._thread and self._thread.is_alive():
|
||
# SDK 的 run() 会阻塞,无法从外部优雅停止
|
||
# 通过设置 enabled=False 让下次检查时退出
|
||
_logger.info("等待旧机器人线程结束...")
|
||
# 无法强制停止,提示用户
|
||
if was_running:
|
||
return {"success": False, "message": "旧机器人正在运行,请稍后重试(SDK 不支持强制停止)"}
|
||
|
||
# 启动新的
|
||
self.start()
|
||
return {"success": True, "message": "机器人已启动"}
|
||
|
||
def get_status(self) -> dict:
|
||
"""获取机器人状态"""
|
||
configured = bool(settings.WEWORK_BOT_ID and settings.WEWORK_BOT_SECRET)
|
||
return {
|
||
"enabled": settings.WEWORK_BOT_ENABLED,
|
||
"configured": configured,
|
||
"running": self._running,
|
||
"bot_id": settings.WEWORK_BOT_ID,
|
||
}
|
||
|
||
|
||
# 全局 bot 管理器
|
||
bot_manager = BotManager()
|
||
|
||
|
||
@asynccontextmanager
|
||
async def lifespan(_app: FastAPI) -> AsyncGenerator[None, None]:
|
||
"""
|
||
应用生命周期管理:
|
||
- 启动时:初始化数据目录、验证数据库连接、预热嵌入服务
|
||
- 关闭时:释放数据库连接
|
||
"""
|
||
_logger = logging.getLogger(__name__)
|
||
|
||
# ── 启动 ──
|
||
settings.ensure_dirs()
|
||
await init_db()
|
||
|
||
# 启动企业微信机器人(后台线程)
|
||
bot_manager.start()
|
||
|
||
# 预热嵌入服务(懒加载,首次调用时初始化)
|
||
try:
|
||
from app.services.embedding_service import EmbeddingService
|
||
EmbeddingService.get_instance()
|
||
except Exception as exc:
|
||
_logger.warning("嵌入服务预热失败(将在首次使用时重试): %s", exc)
|
||
|
||
yield
|
||
|
||
# ── 关闭 ──
|
||
await close_db()
|
||
|
||
|
||
# ──────────────────────────── 创建应用 ────────────────────────────
|
||
|
||
app = FastAPI(
|
||
title="HuiBrain - 中文直播教育知识库",
|
||
description="基于向量检索的中文直播教育知识库系统,支持直播文字稿导入、OCR 截图识别、语义搜索等功能。",
|
||
version="1.2.0",
|
||
lifespan=lifespan,
|
||
)
|
||
|
||
# ──────────────────────────── CORS 中间件 ────────────────────────────
|
||
|
||
app.add_middleware(
|
||
CORSMiddleware,
|
||
allow_origins=settings.CORS_ORIGINS,
|
||
allow_credentials=True,
|
||
allow_methods=["*"],
|
||
allow_headers=["*"],
|
||
)
|
||
|
||
|
||
# ──────────────────────────── 全局异常处理 ────────────────────────────
|
||
|
||
@app.exception_handler(Exception)
|
||
async def global_exception_handler(request: Request, exc: Exception) -> JSONResponse:
|
||
"""全局未捕获异常处理"""
|
||
logger = logging.getLogger(__name__)
|
||
logger.error("未处理异常: %s", exc, exc_info=True)
|
||
return JSONResponse(
|
||
status_code=500,
|
||
content={"detail": "服务器内部错误", "message": str(exc)},
|
||
)
|
||
|
||
|
||
# ──────────────────────────── 注册路由 ────────────────────────────
|
||
|
||
from app.api.v1.pages import router as pages_router
|
||
from app.api.v1.search import router as search_router
|
||
from app.api.v1.import_export import router as import_export_router
|
||
from app.api.v1.images import router as images_router
|
||
from app.api.v1.settings import router as settings_router
|
||
|
||
app.include_router(pages_router, prefix="/api/v1/pages", tags=["知识页面"])
|
||
app.include_router(search_router, prefix="/api/v1/search", tags=["语义搜索"])
|
||
app.include_router(import_export_router, prefix="/api/v1/import", tags=["导入导出"])
|
||
app.include_router(images_router, prefix="/api/v1/images", tags=["图片OCR"])
|
||
app.include_router(settings_router, prefix="/api/v1/settings", tags=["系统设置"])
|
||
|
||
|
||
# ──────────────────────────── 健康检查 ────────────────────────────
|
||
|
||
@app.get("/health", tags=["系统"])
|
||
async def health_check():
|
||
"""健康检查接口"""
|
||
return {"status": "ok", "version": "1.2.0"}
|
||
|
||
|
||
# ──────────────────────────── 图片文件访问 ────────────────────────────
|
||
|
||
@app.get("/data/images/{image_name}", tags=["前端"])
|
||
async def serve_image(image_name: str):
|
||
"""访问 images 目录下的图片文件(用于前端预览)"""
|
||
img_path = settings.images_dir / image_name
|
||
if not img_path.exists():
|
||
return JSONResponse(status_code=404, content={"detail": "图片不存在"})
|
||
return FileResponse(str(img_path))
|
||
|
||
|
||
# ──────────────────────────── 前端页面 ────────────────────────────
|
||
|
||
@app.get("/favicon.ico", include_in_schema=False)
|
||
async def favicon():
|
||
"""返回空响应,避免浏览器 404 报错"""
|
||
return Response(status_code=204)
|
||
|
||
|
||
@app.get("/", tags=["前端"])
|
||
async def index():
|
||
"""返回前端管理页面"""
|
||
static_dir = Path(__file__).parent.parent / "static"
|
||
index_path = static_dir / "index.html"
|
||
if index_path.exists():
|
||
return FileResponse(
|
||
str(index_path),
|
||
headers={"Cache-Control": "no-cache, no-store, must-revalidate"},
|
||
)
|
||
return RedirectResponse(url="/docs")
|