api_request_log_middleware.py 13 KB

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