logging.py 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398
  1. """
  2. API日志中间件
  3. 包含增强的API日志功能,记录入参密文、入参明文、出参明文、出参密文
  4. """
  5. import time
  6. import json
  7. from fastapi import Request
  8. from starlette.middleware.base import BaseHTTPMiddleware
  9. from starlette.responses import Response
  10. from sqlalchemy.orm import Session
  11. from app.database import SessionLocal
  12. from app.models.api_log import APILog
  13. from app.core.logger import logger, get_logger_by_path
  14. from app.core.security import verify_token
  15. from app.models.user import User
  16. from datetime import datetime
  17. from app.utils.timezone import beijing_now_naive
  18. from typing import Any
  19. # 导入增强的API日志功能
  20. from app.utils.api_context import (
  21. set_request_context,
  22. get_decrypted_request,
  23. get_decrypted_response,
  24. )
  25. from app.utils.api_logger import api_logger, redact_for_db
  26. class LoggingResponse(Response):
  27. """用于捕获响应内容的响应类"""
  28. def __init__(self, content: Any, **kwargs):
  29. super().__init__(content, **kwargs)
  30. self.response_content = content
  31. class APILoggingMiddleware(BaseHTTPMiddleware):
  32. """API日志中间件"""
  33. async def dispatch(self, request: Request, call_next):
  34. start_time = time.time()
  35. # 获取请求信息
  36. method = request.method
  37. url = str(request.url)
  38. client_ip = request.client.host if request.client else None
  39. user_agent = request.headers.get("user-agent", "")
  40. # 获取用户ID
  41. user_id = None
  42. try:
  43. auth_header = request.headers.get("authorization", "")
  44. if auth_header.startswith("Bearer "):
  45. token = auth_header[7:] # 移除 "Bearer " 前缀
  46. payload = verify_token(token)
  47. if payload:
  48. username = payload.get("sub")
  49. if username:
  50. # 从数据库获取用户ID(优先查 users 表;admin 用户不应写入 user_id)
  51. db_temp = SessionLocal()
  52. try:
  53. user = db_temp.query(User).filter(User.username == username).first()
  54. if user:
  55. user_id = user.id
  56. finally:
  57. db_temp.close()
  58. except Exception as e:
  59. logger.warning("获取用户ID失败", exc_info=True)
  60. # 获取请求体(如果是POST/PUT/PATCH),其它方法不记录请求体
  61. # 安全端点 /api/v1/ 需在路由内自行读取 body 解密,此处不消费 body 以免导致 422/400
  62. request_body = None
  63. if method in ["POST", "PUT", "PATCH"] and not request.url.path.startswith("/api/v1/"):
  64. try:
  65. body = await request.body()
  66. if body: # 只在有内容时记录
  67. request_body = body.decode('utf-8')[:1000] # 限制长度
  68. except Exception:
  69. pass
  70. # 执行请求
  71. response = await call_next(request)
  72. # 计算执行时间
  73. execution_time = int((time.time() - start_time) * 1000)
  74. # 获取响应状态码
  75. status_code = response.status_code
  76. # 获取响应数据:尽量读取完整响应体(若为 streaming response 则消费 body_iterator 并重建响应)
  77. response_body = None
  78. try:
  79. # 1) 如果 response.body 可用,直接读取
  80. if hasattr(response, "body") and response.body is not None:
  81. body = response.body
  82. if isinstance(body, bytes):
  83. response_text = body.decode('utf-8', errors='ignore')
  84. else:
  85. response_text = str(body)
  86. response_body = response_text
  87. # 2) 否则如果有 body_iterator,消费它并重建响应对象以便后续正确返回给客户端
  88. elif hasattr(response, "body_iterator") and response.body_iterator is not None:
  89. body_bytes = b""
  90. try:
  91. async for chunk in response.body_iterator:
  92. if isinstance(chunk, bytes):
  93. body_bytes += chunk
  94. else:
  95. try:
  96. body_bytes += str(chunk).encode('utf-8')
  97. except:
  98. pass
  99. except Exception:
  100. body_bytes = b""
  101. # 重建响应以保持行为一致
  102. try:
  103. from starlette.responses import Response as StarletteResponse
  104. headers = dict(response.headers) if hasattr(response, "headers") else {}
  105. media_type = getattr(response, "media_type", None)
  106. response = StarletteResponse(content=body_bytes, status_code=status_code, headers=headers, media_type=media_type)
  107. except Exception:
  108. pass
  109. try:
  110. response_body = body_bytes.decode('utf-8', errors='ignore')
  111. except:
  112. response_body = str(body_bytes)
  113. except Exception:
  114. response_body = None
  115. # 截断过长内容以防数据库过大,保留前 2000 字符
  116. if response_body:
  117. if len(response_body) > 2000:
  118. response_body = response_body[:2000] + '...'
  119. else:
  120. response_body = f"Status: {status_code}"
  121. # 兼容:某些 Response 实例(如 JSONResponse)可能把内容放在 .media 属性
  122. if response_body and response_body.startswith("Status:") and hasattr(response, "media"):
  123. try:
  124. import json as _json
  125. media = getattr(response, "media")
  126. if media is not None:
  127. try:
  128. response_text_from_media = _json.dumps(media, ensure_ascii=False)
  129. except Exception:
  130. response_text_from_media = str(media)
  131. if response_text_from_media:
  132. response_body = response_text_from_media if len(response_text_from_media) <= 2000 else response_text_from_media[:2000] + '...'
  133. except Exception:
  134. pass
  135. # 异步记录日志(避免阻塞)
  136. try:
  137. db = SessionLocal()
  138. try:
  139. api_log = APILog(
  140. user_id=user_id,
  141. admin_id=scope.get("verified_admin_id") if scope.get("verified_admin_id") else None,
  142. endpoint=url,
  143. method=method,
  144. request_data=request_body if request_body else None,
  145. # store the captured response body (or fallback status)
  146. response_data=response_body if response_body else None,
  147. ip_address=client_ip,
  148. user_agent=user_agent,
  149. response_code=status_code,
  150. execution_time=execution_time,
  151. created_at=beijing_now_naive()
  152. )
  153. db.add(api_log)
  154. db.commit()
  155. except Exception as e:
  156. db.rollback()
  157. logger.exception("记录API日志失败")
  158. finally:
  159. db.close()
  160. except:
  161. pass
  162. return response
  163. class APILoggingASGIMiddleware:
  164. """更可靠的 ASGI 层日志中间件,捕获请求/响应流(用于记录完整返回报文)
  165. 增强功能:
  166. - 记录入参密文(原始请求体)
  167. - 记录入参明文(解密后的请求参数,需要业务层配合设置)
  168. - 记录出参明文(业务响应数据,需要业务层配合设置)
  169. - 记录出参密文(加密后的响应数据)
  170. - 日志文件按天滚动,每天一个日志文件
  171. """
  172. def __init__(self, app):
  173. self.app = app
  174. async def __call__(self, scope, receive, send):
  175. if scope.get("type") != "http":
  176. await self.app(scope, receive, send)
  177. return
  178. start_time = time.time()
  179. method = scope.get("method")
  180. server = scope.get("server") or ("localhost", 80)
  181. path = scope.get("raw_path", b"").decode("utf-8", errors="ignore")
  182. query_string = scope.get("query_string", b"").decode("utf-8", errors="ignore")
  183. scheme = scope.get("scheme", "http")
  184. url = f"{scheme}://{server[0]}:{server[1]}{path}" + (f"?{query_string}" if query_string else "")
  185. client = scope.get("client")
  186. client_ip = client[0] if client else None
  187. headers = {k.decode("utf-8"): v.decode("utf-8") for k, v in scope.get("headers", [])}
  188. user_agent = headers.get("user-agent", "")
  189. # 直接从请求头获取user_id和user_name,进行组合校验
  190. user_id = None
  191. admin_id = None
  192. try:
  193. # 从header获取user_id和user_name
  194. header_user_id = headers.get("x-user-id") or headers.get("X-User-Id")
  195. header_user_name = headers.get("x-user-name") or headers.get("X-User-Name")
  196. if header_user_id and header_user_name:
  197. # 验证header中的user_id和user_name是否匹配
  198. db_temp = SessionLocal()
  199. try:
  200. # 首先尝试从User表查询
  201. user = db_temp.query(User).filter(
  202. User.id == int(header_user_id),
  203. User.username == header_user_name,
  204. User.is_deleted == 0
  205. ).first()
  206. if user:
  207. user_id = user.id
  208. else:
  209. # 如果User表中没有,尝试从Admin表查询
  210. from app.models.admin import Admin
  211. admin = db_temp.query(Admin).filter(
  212. Admin.id == int(header_user_id),
  213. Admin.username == header_user_name
  214. ).first()
  215. if admin:
  216. admin_id = admin.id
  217. finally:
  218. db_temp.close()
  219. except Exception:
  220. # 如果header验证失败,记录为None
  221. user_id = None
  222. admin_id = None
  223. # 包装 receive,收集请求体(仅在有 body 时)
  224. body_bytes = b""
  225. async def receive_wrapper():
  226. nonlocal body_bytes
  227. message = await receive()
  228. if message.get("type") == "http.request":
  229. body_bytes += message.get("body", b"")
  230. return message
  231. # 包装 send,收集响应状态与 body 分片
  232. status_code = 500
  233. resp_chunks = []
  234. async def send_wrapper(message):
  235. nonlocal status_code, resp_chunks
  236. if message.get("type") == "http.response.start":
  237. status_code = message.get("status", 0)
  238. if message.get("type") == "http.response.body":
  239. chunk = message.get("body", b"")
  240. if chunk:
  241. resp_chunks.append(chunk)
  242. await send(message)
  243. # 请求体要等下游(如 secure_endpoint)调用 request.body() 时才会通过 receive_wrapper 填入 body_bytes,
  244. # 因此此处仅初始化上下文;encrypted_request 在 finally 中根据收集到的 body_bytes 再赋值。
  245. set_request_context({
  246. "endpoint": url,
  247. "method": method,
  248. "user_id": user_id,
  249. "client_ip": client_ip,
  250. "encrypted_request": None,
  251. "encrypted_response": None,
  252. "decrypted_request": None,
  253. "decrypted_response": None,
  254. })
  255. error_message = None
  256. try:
  257. # 调用下游应用
  258. await self.app(scope, receive_wrapper, send_wrapper)
  259. except Exception as e:
  260. error_message = str(e)
  261. raise
  262. finally:
  263. try:
  264. # 此时 body_bytes 已由下游调用 request.body() 时通过 receive_wrapper 收集完毕
  265. encrypted_request = None
  266. if body_bytes:
  267. try:
  268. encrypted_request = body_bytes.decode("utf-8", errors="ignore")
  269. except Exception:
  270. encrypted_request = str(body_bytes)
  271. # 收集响应数据(出参密文)
  272. response_body_bytes = b"".join(resp_chunks) if resp_chunks else b""
  273. encrypted_response = None
  274. if response_body_bytes:
  275. try:
  276. encrypted_response = response_body_bytes.decode("utf-8", errors="ignore")
  277. except Exception:
  278. encrypted_response = str(response_body_bytes)
  279. # 计算执行时间
  280. processing_time_ms = int((time.time() - start_time) * 1000)
  281. # 获取解密后的请求和响应数据
  282. decrypted_request = get_decrypted_request()
  283. decrypted_response = get_decrypted_response()
  284. # 记录增强的API日志(包含明文和密文)
  285. api_logger.log_api_call(
  286. endpoint=url,
  287. method=method,
  288. encrypted_request=encrypted_request,
  289. decrypted_request=decrypted_request,
  290. encrypted_response=encrypted_response,
  291. decrypted_response=decrypted_response,
  292. processing_time_ms=processing_time_ms,
  293. status_code=status_code,
  294. user_id=user_id,
  295. client_ip=client_ip,
  296. error_message=error_message,
  297. )
  298. # 根据路径选择对应的logger用于控制台输出
  299. console_logger = get_logger_by_path(path)
  300. # 在控制台打印API入参出参
  301. try:
  302. request_display = encrypted_request[:500] + ('...[truncated]' if len(encrypted_request or "") > 500 else '') if encrypted_request else 'None'
  303. response_display = encrypted_response[:500] + ('...[truncated]' if len(encrypted_response or "") > 500 else '') if encrypted_response else 'None'
  304. console_logger.info(f"[API请求] {method} {url} - 请求数据(密文): {request_display}")
  305. console_logger.info(f"[API响应] {method} {url} - 响应数据(密文): {response_display} - 执行时间: {processing_time_ms}ms")
  306. # 如果有明文数据,也打印明文(脱敏后)
  307. if decrypted_request:
  308. try:
  309. import json
  310. req_json = json.dumps(decrypted_request, ensure_ascii=False, default=str)[:500]
  311. console_logger.info(f"[API请求] {method} {url} - 请求数据(明文): {req_json}...")
  312. except Exception:
  313. pass
  314. if decrypted_response:
  315. try:
  316. import json
  317. res_json = json.dumps(decrypted_response, ensure_ascii=False, default=str)[:500]
  318. console_logger.info(f"[API响应] {method} {url} - 响应数据(明文): {res_json}...")
  319. except Exception:
  320. pass
  321. except Exception as e:
  322. console_logger.warning(f"[API日志] 控制台日志输出失败: {e}")
  323. # 写入数据库:按文档 4.5 记录明文(脱敏),无明文时回退为密文截断
  324. try:
  325. db = SessionLocal()
  326. try:
  327. request_data_for_db = (
  328. redact_for_db(decrypted_request, 2000)
  329. if decrypted_request is not None
  330. else (encrypted_request[:2000] + "..." if encrypted_request and len(encrypted_request) > 2000 else encrypted_request)
  331. )
  332. response_data_for_db = (
  333. redact_for_db(decrypted_response, 2000)
  334. if decrypted_response is not None
  335. else (encrypted_response[:2000] + "..." if encrypted_response and len(encrypted_response) > 2000 else encrypted_response)
  336. )
  337. api_log = APILog(
  338. user_id=user_id,
  339. admin_id=scope.get("verified_admin_id") if scope.get("verified_admin_id") else None,
  340. endpoint=url,
  341. method=method,
  342. request_data=request_data_for_db,
  343. response_data=response_data_for_db,
  344. ip_address=client_ip,
  345. user_agent=user_agent,
  346. response_code=status_code,
  347. execution_time=processing_time_ms,
  348. created_at=beijing_now_naive()
  349. )
  350. db.add(api_log)
  351. db.commit()
  352. console_logger.debug(f"[APILoggingASGI] 成功记录API日志: {url} {method} {status_code} {processing_time_ms}ms")
  353. except Exception as e:
  354. db.rollback()
  355. console_logger.error(f"[APILoggingASGI] 记录API日志失败: {url} {method} {status_code}", exc_info=True)
  356. finally:
  357. db.close()
  358. except Exception as e:
  359. console_logger.error(f"[APILoggingASGI] 创建数据库连接失败: {e}")
  360. except Exception:
  361. pass