logging.py 14 KB

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