api_request_log_middleware.py 8.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276
  1. """统一记录 API 请求/响应出入参的中间件"""
  2. import asyncio
  3. import json
  4. import logging
  5. import time
  6. from typing import Any, Optional, Tuple
  7. from fastapi import HTTPException
  8. from jose import jwt
  9. from starlette.middleware.base import BaseHTTPMiddleware
  10. from starlette.requests import Request
  11. from starlette.responses import Response
  12. from app.core.config import settings
  13. from app.core.database import SessionLocal
  14. logger = logging.getLogger("sqxp")
  15. _SENSITIVE_KEYS = frozenset(
  16. {
  17. "password",
  18. "oldpassword",
  19. "newpassword",
  20. "encryptedlogin",
  21. "authorization",
  22. "token",
  23. "refreshtoken",
  24. "apikey",
  25. }
  26. )
  27. _OMIT_REQ_BYTES = 10 * 1024 * 1024
  28. def _truncate(s: str, max_len: int) -> str:
  29. if not s:
  30. return s
  31. if len(s) <= max_len:
  32. return s
  33. return s[:max_len] + f"\n... [truncated, total {len(s)} chars]"
  34. def _redact_obj(obj: Any) -> Any:
  35. if isinstance(obj, dict):
  36. out = {}
  37. for k, v in obj.items():
  38. lk = str(k).lower()
  39. if (
  40. lk in _SENSITIVE_KEYS
  41. or "password" in lk
  42. or lk in ("encryptedlogin", "refreshtoken")
  43. ):
  44. out[k] = "***"
  45. else:
  46. out[k] = _redact_obj(v)
  47. return out
  48. if isinstance(obj, list):
  49. return [_redact_obj(x) for x in obj[:200]] + (["..."] if len(obj) > 200 else [])
  50. return obj
  51. def _format_body_bytes(raw: bytes, content_type: str, max_bytes: int) -> str:
  52. if not raw:
  53. return ""
  54. if len(raw) > _OMIT_REQ_BYTES:
  55. return f"[body omitted: {len(raw)} bytes]"
  56. if len(raw) > max_bytes:
  57. raw = raw[:max_bytes]
  58. ct = (content_type or "").lower()
  59. if "multipart/form-data" in ct:
  60. return "[multipart/form-data body omitted]"
  61. if "application/json" in ct or ct == "" or "text/" in ct:
  62. try:
  63. text = raw.decode("utf-8", errors="replace")
  64. try:
  65. parsed = json.loads(text)
  66. parsed = _redact_obj(parsed)
  67. return _truncate(
  68. json.dumps(parsed, ensure_ascii=False),
  69. settings.API_REQUEST_LOG_MAX_BODY,
  70. )
  71. except json.JSONDecodeError:
  72. return _truncate(text, settings.API_REQUEST_LOG_MAX_BODY)
  73. except Exception:
  74. return f"[decode error, {len(raw)} bytes]"
  75. return _truncate(raw.decode("utf-8", errors="replace"), settings.API_REQUEST_LOG_MAX_BODY)
  76. def _claims_from_auth(auth_header: Optional[str]) -> Tuple[Optional[str], Optional[str], Optional[str]]:
  77. if not auth_header or not auth_header.startswith("Bearer "):
  78. return None, None, None
  79. token = auth_header[7:].strip()
  80. if not token:
  81. return None, None, None
  82. try:
  83. payload = jwt.decode(
  84. token,
  85. settings.SECRET_KEY,
  86. algorithms=[settings.ALGORITHM],
  87. options={"verify_signature": False, "verify_exp": False},
  88. )
  89. return payload.get("sub"), payload.get("username"), payload.get("role")
  90. except Exception:
  91. return None, None, None
  92. def _should_skip(request: Request) -> bool:
  93. p = request.url.path
  94. if request.method == "OPTIONS":
  95. return True
  96. if p in ("/", "/health", "/docs", "/redoc", "/openapi.json"):
  97. return True
  98. if p.startswith("/docs") or p.startswith("/redoc"):
  99. return True
  100. return False
  101. def _persist_log(
  102. *,
  103. http_method: str,
  104. path: str,
  105. query_string: Optional[str],
  106. request_body: Optional[str],
  107. response_body: Optional[str],
  108. status_code: int,
  109. duration_ms: int,
  110. user_id: Optional[str],
  111. username: Optional[str],
  112. role: Optional[str],
  113. ip_address: Optional[str],
  114. user_agent: Optional[str],
  115. ) -> None:
  116. from datetime import datetime
  117. from app.core.utils import generate_id
  118. from app.models.models import SysApiRequestLog
  119. db = SessionLocal()
  120. try:
  121. row = SysApiRequestLog(
  122. id=generate_id(),
  123. http_method=http_method,
  124. path=_truncate(path, 500),
  125. query_string=_truncate(query_string or "", 2000) or None,
  126. request_body=request_body,
  127. response_body=response_body,
  128. status_code=status_code,
  129. duration_ms=duration_ms,
  130. user_id=user_id,
  131. username=_truncate(username or "", 100) if username else None,
  132. role=role,
  133. ip_address=ip_address,
  134. user_agent=_truncate(user_agent or "", 500) if user_agent else None,
  135. is_del=0,
  136. create_at=datetime.now(),
  137. create_by=None,
  138. create_name=None,
  139. )
  140. db.add(row)
  141. db.commit()
  142. except Exception as e:
  143. logger.warning("api_request_log persist failed: %s", e)
  144. db.rollback()
  145. finally:
  146. db.close()
  147. def _schedule_persist_log(**kwargs) -> None:
  148. """异步提交日志写入任务,不阻塞当前请求。"""
  149. try:
  150. loop = asyncio.get_running_loop()
  151. except RuntimeError:
  152. # 无事件循环时(极少数场景)回退为同步写入,保证日志不丢。
  153. try:
  154. _persist_log(**kwargs)
  155. except Exception as e:
  156. logger.warning("api_request_log persist fallback failed: %s", e)
  157. return
  158. task = loop.create_task(asyncio.to_thread(_persist_log, **kwargs))
  159. def _on_done(t: asyncio.Task) -> None:
  160. try:
  161. t.result()
  162. except Exception as e:
  163. logger.warning("api_request_log async task failed: %s", e)
  164. task.add_done_callback(_on_done)
  165. class ApiRequestLogMiddleware(BaseHTTPMiddleware):
  166. async def dispatch(self, request: Request, call_next):
  167. if not settings.API_REQUEST_LOG_ENABLED or _should_skip(request):
  168. return await call_next(request)
  169. max_b = settings.API_REQUEST_LOG_MAX_BODY_BYTES
  170. content_type = request.headers.get("content-type", "")
  171. try:
  172. body = await request.body()
  173. except Exception as e:
  174. body = b""
  175. logger.warning("api_request_log read body failed: %s", e)
  176. req_text = _format_body_bytes(body, content_type, max_b)
  177. async def receive():
  178. return {"type": "http.request", "body": body, "more_body": False}
  179. request = Request(request.scope, receive)
  180. uid, uname, role = _claims_from_auth(request.headers.get("authorization"))
  181. ip = request.headers.get("x-forwarded-for", "").split(",")[0].strip()
  182. if not ip and request.client:
  183. ip = request.client.host
  184. ua = request.headers.get("user-agent", "")
  185. start = time.perf_counter()
  186. status_code = 500
  187. resp_text = ""
  188. raw_resp = b""
  189. out: Optional[Response] = None
  190. try:
  191. response = await call_next(request)
  192. status_code = response.status_code
  193. if hasattr(response, "body_iterator"):
  194. resp_chunks: list[bytes] = []
  195. async for chunk in response.body_iterator:
  196. resp_chunks.append(chunk)
  197. raw_resp = b"".join(resp_chunks)
  198. elif getattr(response, "body", None) is not None:
  199. raw_resp = response.body
  200. ct_out = response.headers.get("content-type", "")
  201. resp_text = _format_body_bytes(raw_resp, ct_out, max_b)
  202. headers = {k: v for k, v in response.headers.items() if k.lower() != "content-length"}
  203. out = Response(
  204. content=raw_resp,
  205. status_code=response.status_code,
  206. headers=headers,
  207. media_type=response.media_type,
  208. background=response.background,
  209. )
  210. except HTTPException as e:
  211. status_code = e.status_code
  212. d = e.detail
  213. if not isinstance(d, str):
  214. try:
  215. d = json.dumps(d, ensure_ascii=False)
  216. except Exception:
  217. d = str(d)
  218. resp_text = _truncate(d, settings.API_REQUEST_LOG_MAX_BODY)
  219. raise
  220. except Exception as e:
  221. resp_text = _truncate(str(e), settings.API_REQUEST_LOG_MAX_BODY)
  222. raise
  223. finally:
  224. elapsed_ms = int((time.perf_counter() - start) * 1000)
  225. path = request.url.path
  226. qs = request.url.query
  227. _schedule_persist_log(
  228. http_method=request.method,
  229. path=path,
  230. query_string=qs or None,
  231. request_body=req_text or None,
  232. response_body=resp_text or None,
  233. status_code=status_code,
  234. duration_ms=elapsed_ms,
  235. user_id=uid,
  236. username=uname,
  237. role=role,
  238. ip_address=ip,
  239. user_agent=ua,
  240. )
  241. return out