"""HTTP 访问日志与转发内容摘要(可配置是否打出正文预览)。""" from __future__ import annotations import logging import os import time from collections.abc import Callable from typing import Any from starlette.middleware.base import BaseHTTPMiddleware from starlette.requests import Request from starlette.responses import Response access_logger = logging.getLogger("tg_bridge.access") def log_preview_max_chars() -> int: """环境变量 BRIDGE_LOG_PREVIEW_CHARS:>0 时在业务日志中附带截断后的正文/回复预览;未设置或 0 则只打长度。""" raw = os.environ.get("BRIDGE_LOG_PREVIEW_CHARS", "").strip() if not raw: return 0 try: return max(0, int(raw)) except ValueError: return 0 def preview_for_log(text: str | None, max_chars: int) -> str: if not text or max_chars <= 0: return "" s = text.replace("\r", "").replace("\n", "\\n") if len(s) <= max_chars: return s return s[:max_chars] + "..." class AccessLogMiddleware(BaseHTTPMiddleware): """记录每条 HTTP 请求的方法、路径、状态码与耗时(不含鉴权头与 Body)。""" async def dispatch(self, request: Request, call_next: Callable[[Request], Any]) -> Response: start = time.perf_counter() client = request.client.host if request.client else "-" path = request.url.path method = request.method try: response = await call_next(request) except Exception: elapsed_ms = (time.perf_counter() - start) * 1000 access_logger.exception( '%s "%s %s" 未捕获异常 %.1fms', client, method, path, elapsed_ms, ) raise elapsed_ms = (time.perf_counter() - start) * 1000 access_logger.info( '%s "%s %s" %s %.1fms', client, method, path, response.status_code, elapsed_ms, ) return response