main.py 5.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170
  1. from __future__ import annotations
  2. import asyncio
  3. import logging
  4. from contextlib import asynccontextmanager
  5. from typing import AsyncIterator
  6. from fastapi import FastAPI
  7. from app.core.config import settings
  8. from app.core.docs_auth import setup_protected_docs
  9. from app.core.exception_handlers import register_exception_handlers
  10. from app.core.openapi import setup_custom_openapi
  11. from app.core.response import ApiResponse
  12. from app.routers import search
  13. from app.schemas import CacheRefreshResult, CacheStatus
  14. from app.services.cache import (
  15. WORKER_ID,
  16. get_snapshot,
  17. leader_heartbeat,
  18. refresh_cache,
  19. scheduler_loop,
  20. try_become_leader,
  21. wait_cache_ready,
  22. )
  23. from app.services.redis_client import (
  24. KEY_CACHE_DATA,
  25. KEY_LOCK_LEADER,
  26. close_redis,
  27. get_redis,
  28. release_lock,
  29. )
  30. logging.basicConfig(
  31. level=logging.INFO,
  32. format="%(asctime)s | %(levelname)s | %(name)s | %(message)s",
  33. )
  34. logger = logging.getLogger(__name__)
  35. @asynccontextmanager
  36. async def lifespan(app: FastAPI) -> AsyncIterator[None]:
  37. """启动:选举 leader → 仅 leader 刷新缓存与跑定时任务;关闭:释放资源。"""
  38. is_leader = await try_become_leader()
  39. tasks: list[asyncio.Task[None]] = []
  40. if is_leader:
  41. # Redis 已有缓存就跳过启动刷新;只让定时任务在配置时间触发
  42. cache_exists = False
  43. try:
  44. r = await get_redis()
  45. cache_exists = bool(await r.exists(KEY_CACHE_DATA))
  46. except Exception as exc: # noqa: BLE001
  47. logger.warning("[%s] 检查 Redis 缓存失败,将执行首次刷新: %s", WORKER_ID, exc)
  48. if cache_exists:
  49. logger.info("[%s] 当选 leader,Redis 已有缓存,跳过启动刷新", WORKER_ID)
  50. else:
  51. logger.info("[%s] 当选 leader,Redis 无缓存,执行首次刷新", WORKER_ID)
  52. await refresh_cache()
  53. tasks.append(
  54. asyncio.create_task(scheduler_loop(), name="faq-scheduler")
  55. )
  56. tasks.append(
  57. asyncio.create_task(leader_heartbeat(), name="leader-heartbeat")
  58. )
  59. logger.info("[%s] 定时任务与 leader 心跳已启动", WORKER_ID)
  60. else:
  61. logger.info("[%s] 不是 leader,等待缓存就绪…", WORKER_ID)
  62. ready = await wait_cache_ready()
  63. if ready:
  64. logger.info("[%s] 共享缓存已就绪", WORKER_ID)
  65. else:
  66. logger.warning(
  67. "[%s] 等待缓存就绪超时,搜索将以空 sec_ids 降级运行",
  68. WORKER_ID,
  69. )
  70. try:
  71. yield
  72. finally:
  73. logger.info("[%s] 关闭:取消后台任务…", WORKER_ID)
  74. for task in tasks:
  75. task.cancel()
  76. for task in tasks:
  77. try:
  78. await task
  79. except asyncio.CancelledError:
  80. pass
  81. if is_leader:
  82. # 主动释放 leader 锁,让其他 worker 可以更快接管
  83. await release_lock(KEY_LOCK_LEADER, WORKER_ID)
  84. await close_redis()
  85. # 仅开发环境暴露 API 文档;生产环境一律关闭,避免泄露接口结构
  86. _docs_enabled = settings.app_env.lower() == "dev"
  87. app = FastAPI(
  88. title="Zendesk FAQ Keyword Search",
  89. description="封装 Zendesk Help Center 搜索;FAQ",
  90. version="1.0.0",
  91. lifespan=lifespan,
  92. docs_url=None,
  93. redoc_url=None,
  94. openapi_url=None,
  95. )
  96. # 注册全局异常处理器(统一错误响应格式 {code, data, msg})
  97. register_exception_handlers(app)
  98. # 覆写 OpenAPI 文档:让所有错误响应(4xx/5xx)使用统一信封格式
  99. setup_custom_openapi(app)
  100. # 注册受 HTTP Basic 认证保护的 /faq/docs、/faq/redoc、/faq/openapi.json
  101. setup_protected_docs(app)
  102. # 路由
  103. app.include_router(search.router)
  104. @app.get(
  105. "/faq/health",
  106. response_model=ApiResponse[dict[str, str]],
  107. tags=["meta"],
  108. )
  109. async def health() -> ApiResponse[dict[str, str]]:
  110. """健康检查。"""
  111. return ApiResponse.ok(data={"status": "ok"})
  112. @app.get(
  113. "/faq/health/cache",
  114. response_model=ApiResponse[CacheStatus],
  115. tags=["meta"],
  116. )
  117. async def cache_status() -> ApiResponse[CacheStatus]:
  118. """缓存状态查看。"""
  119. snap = await get_snapshot()
  120. return ApiResponse.ok(
  121. data=CacheStatus(
  122. sec_ids_count=len(snap["sec_ids"]),
  123. last_updated_at=snap["last_updated_at"],
  124. next_refresh_at=snap["next_refresh_at"],
  125. )
  126. )
  127. @app.post(
  128. "/faq/admin/cache/refresh",
  129. response_model=ApiResponse[CacheRefreshResult],
  130. tags=["meta"],
  131. )
  132. async def refresh_cache_now() -> ApiResponse[CacheRefreshResult]:
  133. """手动触发刷新(运维 / 测试用途)。
  134. 使用 force=True 跳过去重锁,确保管理员请求立即生效。
  135. """
  136. await refresh_cache(force=True)
  137. snap = await get_snapshot()
  138. return ApiResponse.ok(
  139. data=CacheRefreshResult(
  140. sec_ids_count=len(snap["sec_ids"]),
  141. last_updated_at=snap["last_updated_at"],
  142. ),
  143. msg="缓存刷新成功",
  144. )