api_request_log_middleware.py 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349
  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. from app.core.oper_log_context import clear_oper_log_meta, snapshot_oper_log_meta
  15. from app.core.oper_log_infer import business_type_from_path, title_from_path
  16. logger = logging.getLogger("sqxp")
  17. _SENSITIVE_KEYS = frozenset(
  18. {
  19. "password",
  20. "oldpassword",
  21. "newpassword",
  22. "encryptedlogin",
  23. "authorization",
  24. "token",
  25. "refreshtoken",
  26. "apikey",
  27. }
  28. )
  29. _OMIT_REQ_BYTES = 10 * 1024 * 1024
  30. def _truncate(s: str, max_len: int) -> str:
  31. if not s:
  32. return s
  33. if len(s) <= max_len:
  34. return s
  35. return s[:max_len] + f"\n... [truncated, total {len(s)} chars]"
  36. # MySQL TEXT 列上限为 65535 字节(非字符);utf8mb4 下中文等多字节字符需按字节截断。
  37. _DB_TEXT_MAX_BYTES = 60000
  38. def _truncate_utf8_bytes(s: str, max_bytes: int = _DB_TEXT_MAX_BYTES) -> str:
  39. if not s:
  40. return s
  41. encoded = s.encode("utf-8")
  42. if len(encoded) <= max_bytes:
  43. return s
  44. truncated = encoded[:max_bytes]
  45. while truncated and (truncated[-1] & 0xC0) == 0x80:
  46. truncated = truncated[:-1]
  47. text = truncated.decode("utf-8", errors="ignore")
  48. return (
  49. text
  50. + f"\n... [truncated, total {len(s)} chars, {len(encoded)} bytes]"
  51. )
  52. def _redact_obj(obj: Any) -> Any:
  53. if isinstance(obj, dict):
  54. out = {}
  55. for k, v in obj.items():
  56. lk = str(k).lower()
  57. if (
  58. lk in _SENSITIVE_KEYS
  59. or "password" in lk
  60. or lk in ("encryptedlogin", "refreshtoken")
  61. ):
  62. out[k] = "***"
  63. else:
  64. out[k] = _redact_obj(v)
  65. return out
  66. if isinstance(obj, list):
  67. return [_redact_obj(x) for x in obj[:200]] + (["..."] if len(obj) > 200 else [])
  68. return obj
  69. def _format_body_bytes(raw: bytes, content_type: str, max_bytes: int) -> str:
  70. if not raw:
  71. return ""
  72. if len(raw) > _OMIT_REQ_BYTES:
  73. return f"[body omitted: {len(raw)} bytes]"
  74. if len(raw) > max_bytes:
  75. raw = raw[:max_bytes]
  76. ct = (content_type or "").lower()
  77. if "multipart/form-data" in ct:
  78. return "[multipart/form-data body omitted]"
  79. if "application/json" in ct or ct == "" or "text/" in ct:
  80. try:
  81. text = raw.decode("utf-8", errors="replace")
  82. try:
  83. parsed = json.loads(text)
  84. parsed = _redact_obj(parsed)
  85. return _truncate(
  86. json.dumps(parsed, ensure_ascii=False),
  87. settings.API_REQUEST_LOG_MAX_BODY,
  88. )
  89. except json.JSONDecodeError:
  90. return _truncate(text, settings.API_REQUEST_LOG_MAX_BODY)
  91. except Exception:
  92. return f"[decode error, {len(raw)} bytes]"
  93. return _truncate(raw.decode("utf-8", errors="replace"), settings.API_REQUEST_LOG_MAX_BODY)
  94. def _claims_from_auth(
  95. auth_header: Optional[str],
  96. ) -> Tuple[Optional[str], Optional[str], Optional[str], Optional[str], Optional[str]]:
  97. if not auth_header or not auth_header.startswith("Bearer "):
  98. return None, None, None, None, None
  99. token = auth_header[7:].strip()
  100. if not token:
  101. return None, None, None, None, None
  102. try:
  103. payload = jwt.decode(
  104. token,
  105. settings.SECRET_KEY,
  106. algorithms=[settings.ALGORITHM],
  107. options={"verify_signature": False, "verify_exp": False},
  108. )
  109. return (
  110. payload.get("sub"),
  111. payload.get("username"),
  112. payload.get("role"),
  113. payload.get("real_name"),
  114. payload.get("tenant_no"),
  115. )
  116. except Exception:
  117. return None, None, None, None, None
  118. def _should_skip_exclude(path: str) -> bool:
  119. raw = (settings.API_REQUEST_LOG_EXCLUDE_PATHS or "").strip()
  120. if not raw:
  121. return False
  122. for item in raw.split(","):
  123. prefix = item.strip()
  124. if not prefix:
  125. continue
  126. if path == prefix or path.startswith(prefix):
  127. return True
  128. return False
  129. def _should_skip(request: Request) -> bool:
  130. p = request.url.path
  131. if request.method == "OPTIONS":
  132. return True
  133. if p in ("/", "/health", "/docs", "/redoc", "/openapi.json"):
  134. return True
  135. if p.startswith("/docs") or p.startswith("/redoc"):
  136. return True
  137. if _should_skip_exclude(p):
  138. return True
  139. return False
  140. def _persist_log(
  141. *,
  142. http_method: str,
  143. path: str,
  144. query_string: Optional[str],
  145. request_body: Optional[str],
  146. response_body: Optional[str],
  147. status_code: int,
  148. duration_ms: int,
  149. user_id: Optional[str],
  150. username: Optional[str],
  151. role: Optional[str],
  152. ip_address: Optional[str],
  153. user_agent: Optional[str],
  154. create_name: Optional[str] = None,
  155. tenant_no: Optional[str] = None,
  156. business_title: Optional[str] = None,
  157. business_type: Optional[int] = None,
  158. business_id: Optional[str] = None,
  159. oper_summary: Optional[str] = None,
  160. ) -> None:
  161. from datetime import datetime
  162. from app.core.utils import generate_id
  163. from app.models.models import SysApiRequestLog
  164. db = SessionLocal()
  165. try:
  166. row = SysApiRequestLog(
  167. id=generate_id(),
  168. http_method=http_method,
  169. path=_truncate(path, 500),
  170. query_string=_truncate(query_string or "", 2000) or None,
  171. request_body=_truncate_utf8_bytes(request_body) if request_body else None,
  172. response_body=_truncate_utf8_bytes(response_body) if response_body else None,
  173. status_code=status_code,
  174. duration_ms=duration_ms,
  175. user_id=user_id,
  176. username=_truncate(username or "", 100) if username else None,
  177. role=role,
  178. ip_address=ip_address,
  179. user_agent=_truncate(user_agent or "", 500) if user_agent else None,
  180. tenant_no=_truncate(tenant_no or "", 64) if tenant_no else None,
  181. business_title=_truncate(business_title or "", 100) if business_title else None,
  182. business_type=business_type,
  183. business_id=_truncate(business_id or "", 64) if business_id else None,
  184. oper_summary=_truncate(oper_summary or "", 500) if oper_summary else None,
  185. is_del=0,
  186. create_at=datetime.now(),
  187. create_by=user_id,
  188. create_name=_truncate(create_name or "", 100) if create_name else None,
  189. )
  190. db.add(row)
  191. db.commit()
  192. except Exception as e:
  193. logger.warning("api_request_log persist failed: %s", e)
  194. db.rollback()
  195. finally:
  196. db.close()
  197. def _schedule_persist_log(**kwargs) -> None:
  198. """异步提交日志写入任务,不阻塞当前请求。"""
  199. try:
  200. loop = asyncio.get_running_loop()
  201. except RuntimeError:
  202. # 无事件循环时(极少数场景)回退为同步写入,保证日志不丢。
  203. try:
  204. _persist_log(**kwargs)
  205. except Exception as e:
  206. logger.warning("api_request_log persist fallback failed: %s", e)
  207. return
  208. task = loop.create_task(asyncio.to_thread(_persist_log, **kwargs))
  209. def _on_done(t: asyncio.Task) -> None:
  210. try:
  211. t.result()
  212. except Exception as e:
  213. logger.warning("api_request_log async task failed: %s", e)
  214. task.add_done_callback(_on_done)
  215. class ApiRequestLogMiddleware(BaseHTTPMiddleware):
  216. async def dispatch(self, request: Request, call_next):
  217. if not settings.API_REQUEST_LOG_ENABLED or _should_skip(request):
  218. return await call_next(request)
  219. max_b = settings.API_REQUEST_LOG_MAX_BODY_BYTES
  220. content_type = request.headers.get("content-type", "")
  221. try:
  222. body = await request.body()
  223. except Exception as e:
  224. body = b""
  225. logger.warning("api_request_log read body failed: %s", e)
  226. req_text = _format_body_bytes(body, content_type, max_b)
  227. async def receive():
  228. return {"type": "http.request", "body": body, "more_body": False}
  229. request = Request(request.scope, receive)
  230. uid, uname, role, real_name, tenant_no = _claims_from_auth(request.headers.get("authorization"))
  231. ip = request.headers.get("x-forwarded-for", "").split(",")[0].strip()
  232. if not ip and request.client:
  233. ip = request.client.host
  234. ua = request.headers.get("user-agent", "")
  235. start = time.perf_counter()
  236. status_code = 500
  237. resp_text = ""
  238. raw_resp = b""
  239. out: Optional[Response] = None
  240. try:
  241. response = await call_next(request)
  242. status_code = response.status_code
  243. if hasattr(response, "body_iterator"):
  244. resp_chunks: list[bytes] = []
  245. async for chunk in response.body_iterator:
  246. resp_chunks.append(chunk)
  247. raw_resp = b"".join(resp_chunks)
  248. elif getattr(response, "body", None) is not None:
  249. raw_resp = response.body
  250. ct_out = response.headers.get("content-type", "")
  251. resp_text = _format_body_bytes(raw_resp, ct_out, max_b)
  252. headers = {k: v for k, v in response.headers.items() if k.lower() != "content-length"}
  253. out = Response(
  254. content=raw_resp,
  255. status_code=response.status_code,
  256. headers=headers,
  257. media_type=response.media_type,
  258. background=response.background,
  259. )
  260. except HTTPException as e:
  261. status_code = e.status_code
  262. d = e.detail
  263. if not isinstance(d, str):
  264. try:
  265. d = json.dumps(d, ensure_ascii=False)
  266. except Exception:
  267. d = str(d)
  268. resp_text = _truncate(d, settings.API_REQUEST_LOG_MAX_BODY)
  269. raise
  270. except Exception as e:
  271. resp_text = _truncate(str(e), settings.API_REQUEST_LOG_MAX_BODY)
  272. raise
  273. finally:
  274. elapsed_ms = int((time.perf_counter() - start) * 1000)
  275. path = request.url.path
  276. qs = request.url.query
  277. meta = snapshot_oper_log_meta()
  278. biz_title = meta.business_title if meta and meta.business_title else title_from_path(path)
  279. biz_type = (
  280. meta.business_type
  281. if meta and meta.business_type is not None
  282. else business_type_from_path(path, request.method)
  283. )
  284. biz_id = meta.business_id if meta else None
  285. oper_summary = meta.oper_summary if meta else None
  286. effective_tenant = tenant_no or settings.DEFAULT_TENANT_NO
  287. _schedule_persist_log(
  288. http_method=request.method,
  289. path=path,
  290. query_string=qs or None,
  291. request_body=req_text or None,
  292. response_body=resp_text or None,
  293. status_code=status_code,
  294. duration_ms=elapsed_ms,
  295. user_id=uid,
  296. username=uname,
  297. role=role,
  298. ip_address=ip,
  299. user_agent=ua,
  300. create_name=real_name,
  301. tenant_no=effective_tenant,
  302. business_title=biz_title,
  303. business_type=biz_type,
  304. business_id=biz_id,
  305. oper_summary=oper_summary,
  306. )
  307. clear_oper_log_meta()
  308. return out