from __future__ import annotations import asyncio import logging from contextlib import asynccontextmanager from typing import AsyncIterator from fastapi import FastAPI from app.core.config import settings from app.core.docs_auth import setup_protected_docs from app.core.exception_handlers import register_exception_handlers from app.core.openapi import setup_custom_openapi from app.core.response import ApiResponse from app.routers import search from app.schemas import CacheRefreshResult, CacheStatus from app.services.cache import ( WORKER_ID, get_snapshot, leader_heartbeat, refresh_cache, scheduler_loop, try_become_leader, wait_cache_ready, ) from app.services.redis_client import ( KEY_CACHE_DATA, KEY_LOCK_LEADER, close_redis, get_redis, release_lock, ) logging.basicConfig( level=logging.INFO, format="%(asctime)s | %(levelname)s | %(name)s | %(message)s", ) logger = logging.getLogger(__name__) @asynccontextmanager async def lifespan(app: FastAPI) -> AsyncIterator[None]: """启动:选举 leader → 仅 leader 刷新缓存与跑定时任务;关闭:释放资源。""" is_leader = await try_become_leader() tasks: list[asyncio.Task[None]] = [] if is_leader: # Redis 已有缓存就跳过启动刷新;只让定时任务在配置时间触发 cache_exists = False try: r = await get_redis() cache_exists = bool(await r.exists(KEY_CACHE_DATA)) except Exception as exc: # noqa: BLE001 logger.warning("[%s] 检查 Redis 缓存失败,将执行首次刷新: %s", WORKER_ID, exc) if cache_exists: logger.info("[%s] 当选 leader,Redis 已有缓存,跳过启动刷新", WORKER_ID) else: logger.info("[%s] 当选 leader,Redis 无缓存,执行首次刷新", WORKER_ID) await refresh_cache() tasks.append( asyncio.create_task(scheduler_loop(), name="faq-scheduler") ) tasks.append( asyncio.create_task(leader_heartbeat(), name="leader-heartbeat") ) logger.info("[%s] 定时任务与 leader 心跳已启动", WORKER_ID) else: logger.info("[%s] 不是 leader,等待缓存就绪…", WORKER_ID) ready = await wait_cache_ready() if ready: logger.info("[%s] 共享缓存已就绪", WORKER_ID) else: logger.warning( "[%s] 等待缓存就绪超时,搜索将以空 sec_ids 降级运行", WORKER_ID, ) try: yield finally: logger.info("[%s] 关闭:取消后台任务…", WORKER_ID) for task in tasks: task.cancel() for task in tasks: try: await task except asyncio.CancelledError: pass if is_leader: # 主动释放 leader 锁,让其他 worker 可以更快接管 await release_lock(KEY_LOCK_LEADER, WORKER_ID) await close_redis() # 仅开发环境暴露 API 文档;生产环境一律关闭,避免泄露接口结构 _docs_enabled = settings.app_env.lower() == "dev" app = FastAPI( title="Zendesk FAQ Keyword Search", description="封装 Zendesk Help Center 搜索;FAQ", version="1.0.0", lifespan=lifespan, docs_url=None, redoc_url=None, openapi_url=None, ) # 注册全局异常处理器(统一错误响应格式 {code, data, msg}) register_exception_handlers(app) # 覆写 OpenAPI 文档:让所有错误响应(4xx/5xx)使用统一信封格式 setup_custom_openapi(app) # 注册受 HTTP Basic 认证保护的 /faq/docs、/faq/redoc、/faq/openapi.json setup_protected_docs(app) # 路由 app.include_router(search.router) @app.get( "/faq/health", response_model=ApiResponse[dict[str, str]], tags=["meta"], ) async def health() -> ApiResponse[dict[str, str]]: """健康检查。""" return ApiResponse.ok(data={"status": "ok"}) @app.get( "/faq/health/cache", response_model=ApiResponse[CacheStatus], tags=["meta"], ) async def cache_status() -> ApiResponse[CacheStatus]: """缓存状态查看。""" snap = await get_snapshot() return ApiResponse.ok( data=CacheStatus( sec_ids_count=len(snap["sec_ids"]), last_updated_at=snap["last_updated_at"], next_refresh_at=snap["next_refresh_at"], ) ) @app.post( "/faq/admin/cache/refresh", response_model=ApiResponse[CacheRefreshResult], tags=["meta"], ) async def refresh_cache_now() -> ApiResponse[CacheRefreshResult]: """手动触发刷新(运维 / 测试用途)。 使用 force=True 跳过去重锁,确保管理员请求立即生效。 """ await refresh_cache(force=True) snap = await get_snapshot() return ApiResponse.ok( data=CacheRefreshResult( sec_ids_count=len(snap["sec_ids"]), last_updated_at=snap["last_updated_at"], ), msg="缓存刷新成功", )