fastapi_app.py 8.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231
  1. import asyncio
  2. import inspect
  3. import json
  4. import re
  5. import time
  6. import uuid
  7. from contextlib import asynccontextmanager
  8. from pathlib import Path
  9. import uvicorn
  10. from fastapi import FastAPI, Request
  11. from fastapi.exceptions import RequestValidationError
  12. from starlette.exceptions import HTTPException as StarletteHTTPException
  13. from api.base import BaseApi
  14. from api.common import api_response
  15. from api.errors import BusinessValidationError, DatabaseQueryError, ServiceBusyError
  16. from api.reporting import cloud_log_worker, set_failure
  17. from api.routes import api_router
  18. from config import settings
  19. from core.base.async_mysql_client import AsyncMySQLClient
  20. from core.utils.log.logger_manager import LoggerManager
  21. API_PATH = '/api/v1/crawler/videos/query'
  22. HEALTH_PATH = '/health'
  23. READY_PATH = '/ready'
  24. MAX_REQUEST_BODY_SIZE = 1024 * 1024
  25. PROJECT_ROOT = Path(__file__).resolve().parents[1]
  26. def route_code_location(route) -> str:
  27. """返回路由最终业务方法及其项目源码位置。"""
  28. endpoint = inspect.unwrap(getattr(route, 'endpoint', None))
  29. if endpoint is None:
  30. return getattr(route, 'name', '')
  31. module = getattr(endpoint, '__module__', '')
  32. qualname = getattr(endpoint, '__qualname__', getattr(route, 'name', ''))
  33. target = f'{module}.{qualname}'.strip('.')
  34. source_file = inspect.getsourcefile(endpoint)
  35. if not source_file:
  36. return target
  37. source_path = Path(source_file).resolve()
  38. try:
  39. display_path = source_path.relative_to(PROJECT_ROOT)
  40. except ValueError:
  41. # 第三方框架路由只展示模块方法,不打印虚拟环境内部路径。
  42. return target
  43. if display_path.parts and display_path.parts[0] == '.venv':
  44. return target
  45. try:
  46. line_number = inspect.getsourcelines(endpoint)[1]
  47. except (OSError, TypeError):
  48. return f'{target} [{display_path}]'
  49. return f'{target} [{display_path}:{line_number}]'
  50. def print_api_routes(app: FastAPI) -> None:
  51. def iter_routes(routes):
  52. for route in routes:
  53. included_router = getattr(route, 'original_router', None)
  54. if included_router is not None:
  55. yield from iter_routes(included_router.routes)
  56. else:
  57. yield route
  58. print('\n已注册 API:', flush=True)
  59. for route in iter_routes(app.routes):
  60. path = getattr(route, 'path', None)
  61. methods = ','.join(sorted(getattr(route, 'methods', None) or []))
  62. if path and methods:
  63. print(
  64. f' {methods:<12} {path:<36} -> {route_code_location(route)}',
  65. flush=True,
  66. )
  67. print('', flush=True)
  68. def parse_json_text(text: str):
  69. if not text:
  70. return None
  71. try:
  72. return json.loads(text)
  73. except (TypeError, ValueError):
  74. return text
  75. async def access_middleware(request: Request, call_next):
  76. """统一入口和出口:上下文、Body、异常响应、请求ID和访问日志全部在此闭环。"""
  77. incoming_request_id = request.headers.get('X-Request-ID', '').strip()
  78. if not re.fullmatch(r'[A-Za-z0-9._-]{1,128}', incoming_request_id):
  79. incoming_request_id = uuid.uuid4().hex
  80. request.state.request_id = incoming_request_id
  81. request.state.started_at = time.perf_counter()
  82. request.state.request_params = {'query': dict(request.query_params), 'body': None}
  83. response = None
  84. try:
  85. raw_body = await request.body()
  86. request.state.request_params['body'] = parse_json_text(
  87. raw_body.decode('utf-8', errors='replace')
  88. )
  89. if len(raw_body) > MAX_REQUEST_BODY_SIZE:
  90. exc = ValueError('请求体不能超过1MB')
  91. set_failure(request, 'request_validation', exc)
  92. response = api_response(code=413, msg=str(exc))
  93. else:
  94. response = await call_next(request)
  95. except Exception as exc:
  96. response = BaseApi.exception_response(request, exc)
  97. response.headers['X-Request-ID'] = request.state.request_id
  98. # BaseApi负责业务接口上报;未进入BaseApi的校验错误、404/405及健康检查在此兜底。
  99. if not getattr(request.state, 'api_reported', False):
  100. await BaseApi.report_once(request, response)
  101. return response
  102. async def api_exception_handler(request: Request, exc: Exception):
  103. return BaseApi.exception_response(request, exc)
  104. async def health():
  105. return api_response({'status': 'ok'})
  106. async def ready(request: Request):
  107. try:
  108. async with asyncio.timeout(min(3, settings.API_QUERY_TIMEOUT)):
  109. row = await request.app.state.mysql.fetch_one('SELECT 1 AS ok')
  110. if not row or row.get('ok') != 1:
  111. raise DatabaseQueryError('database readiness check failed')
  112. return api_response({'status': 'ready'})
  113. except Exception as exc:
  114. set_failure(request, 'readiness', exc, f'{type(exc).__name__}: {exc}')
  115. return api_response(code=503, msg='not ready')
  116. @asynccontextmanager
  117. async def app_lifespan(app: FastAPI):
  118. logger = LoggerManager.get_logger(platform='chui_zhi', mode='api')
  119. aliyun_logger = LoggerManager.get_aliyun_logger(
  120. platform='chui_zhi',
  121. mode='api',
  122. env=settings.ENV,
  123. project=settings.API_ALIYUN_LOG_PROJECT,
  124. logstore=settings.API_ALIYUN_LOGSTORE,
  125. endpoint=settings.API_ALIYUN_LOG_ENDPOINT,
  126. )
  127. mysql = AsyncMySQLClient(
  128. host=settings.DB_HOST,
  129. port=settings.DB_PORT,
  130. user=settings.DB_USER,
  131. password=settings.DB_PASSWORD,
  132. db=settings.DB_NAME,
  133. charset=settings.DB_CHARSET,
  134. minsize=min(5, settings.DB_POOL_SIZE),
  135. maxsize=settings.DB_POOL_SIZE,
  136. pool_recycle=settings.DB_POOL_RECYCLE,
  137. logger=logger,
  138. aliyun_logr=None,
  139. )
  140. await mysql.init_pool()
  141. concurrency = min(settings.API_MAX_CONCURRENT_REQUESTS, settings.DB_POOL_SIZE)
  142. app.state.mysql = mysql
  143. app.state.query_semaphore = asyncio.Semaphore(concurrency)
  144. app.state.logger = logger
  145. app.state.aliyun_logger = aliyun_logger
  146. app.state.cloud_log_queue = asyncio.Queue(maxsize=settings.API_LOG_QUEUE_SIZE)
  147. app.state.cloud_log_worker = asyncio.create_task(cloud_log_worker(app))
  148. logger.info(
  149. f'垂直spider API启动: framework=fastapi, db_pool={settings.DB_POOL_SIZE}, '
  150. f'query_concurrency={concurrency}, '
  151. f'sls={settings.API_ALIYUN_LOG_PROJECT}/{settings.API_ALIYUN_LOGSTORE}'
  152. )
  153. print_api_routes(app)
  154. yield
  155. queue = app.state.cloud_log_queue
  156. try:
  157. await asyncio.wait_for(queue.join(), timeout=settings.API_LOG_FLUSH_TIMEOUT)
  158. except asyncio.TimeoutError:
  159. logger.error(f'API关闭时日志队列未完全清空: remaining={queue.qsize()}')
  160. app.state.cloud_log_worker.cancel()
  161. try:
  162. await app.state.cloud_log_worker
  163. except asyncio.CancelledError:
  164. pass
  165. else:
  166. await queue.put(None)
  167. await app.state.cloud_log_worker
  168. await mysql.close()
  169. logger.info('垂直spider API已关闭')
  170. def create_app(manage_resources: bool = True) -> FastAPI:
  171. app = FastAPI(
  172. title='AutoScraperX API',
  173. version='1.0.0',
  174. lifespan=app_lifespan if manage_resources else None,
  175. )
  176. app.middleware('http')(access_middleware)
  177. app.add_exception_handler(RequestValidationError, api_exception_handler)
  178. app.add_exception_handler(BusinessValidationError, api_exception_handler)
  179. app.add_exception_handler(ServiceBusyError, api_exception_handler)
  180. app.add_exception_handler(asyncio.TimeoutError, api_exception_handler)
  181. app.add_exception_handler(DatabaseQueryError, api_exception_handler)
  182. app.add_exception_handler(StarletteHTTPException, api_exception_handler)
  183. app.add_exception_handler(Exception, api_exception_handler)
  184. app.include_router(api_router)
  185. app.add_api_route(HEALTH_PATH, health, methods=['GET'], name='health', include_in_schema=False)
  186. app.add_api_route(READY_PATH, ready, methods=['GET'], name='ready', include_in_schema=False)
  187. return app
  188. app = create_app()
  189. def main():
  190. uvicorn.run(
  191. app,
  192. host=settings.API_HOST,
  193. port=settings.API_PORT,
  194. access_log=False,
  195. )
  196. if __name__ == '__main__':
  197. main()