fastapi_app.py 7.0 KB

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