"""API 请求/响应日志中间件。 记录每次 API 调用的完整信息:方法、路径、请求头、查询参数、请求体、 响应状态码、响应头、响应体。 日志输出到 `api_access` logger → `logs/api-access-YYYY-MM-DD.log`。 """ import json import logging import time api_logger = logging.getLogger('api_access') # 跳过日志的路径前缀(静态文件、admin 等) _SKIP_PREFIXES = ('/static/', '/admin/', '/api/schema/', '/api/docs/') # 请求体/响应体最大记录长度(字符) _MAX_BODY_LEN = 2000 # 需要记录的请求头(Django 中请求头加 HTTP_ 前缀并大写) _LOG_REQUEST_HEADERS = ( 'HTTP_AUTHORIZATION', 'HTTP_ACCEPT', 'HTTP_USER_AGENT', 'HTTP_X_FORWARDED_FOR', 'HTTP_X_REAL_IP', ) def _truncate(text, max_len=_MAX_BODY_LEN): if len(text) > max_len: return text[:max_len] + f'... (truncated, total {len(text)} chars)' return text def _safe_json_body(body_bytes, content_type=''): """尝试将请求/响应体解析为 JSON,失败则返回原始文本摘要。""" if not body_bytes: return '' # multipart (文件上传) 不记录原始内容 if 'multipart' in content_type: return f'' try: text = body_bytes.decode('utf-8') data = json.loads(text) return _truncate(json.dumps(data, ensure_ascii=False)) except (UnicodeDecodeError, json.JSONDecodeError): return _truncate(body_bytes.decode('utf-8', errors='replace')) class APIAccessLogMiddleware: """记录 /api/ 请求与响应的中间件。""" def __init__(self, get_response): self.get_response = get_response @staticmethod def _collect_request_headers(request): """提取关键请求头。""" headers = {} ct = request.content_type or request.META.get('CONTENT_TYPE', '') if ct: headers['Content-Type'] = ct for meta_key in _LOG_REQUEST_HEADERS: val = request.META.get(meta_key) if val: name = meta_key.replace('HTTP_', '').replace('_', '-').title() headers[name] = val return headers @staticmethod def _collect_response_headers(response): """提取关键响应头。""" keys = ('Content-Type', 'Content-Length', 'Allow', 'X-Request-Id', 'Retry-After', 'WWW-Authenticate') headers = {} for k in keys: v = response.get(k) if v: headers[k] = v return headers def __call__(self, request): path = request.path if not path.startswith('/api/') or any(path.startswith(p) for p in _SKIP_PREFIXES): return self.get_response(request) start = time.time() req_headers = self._collect_request_headers(request) req_content_type = request.content_type or '' if 'multipart' in req_content_type: form_fields = dict(request.POST) file_info = { name: f'' for name, f in request.FILES.items() } all_fields = {**form_fields, **file_info} req_body = _truncate(json.dumps(all_fields, ensure_ascii=False)) if all_fields else '' else: req_body = _safe_json_body(request.body, req_content_type) query = dict(request.GET) if request.GET else '' user_id = None response = self.get_response(request) if hasattr(request, 'user') and request.user and request.user.is_authenticated: user_id = request.user.id duration_ms = int((time.time() - start) * 1000) resp_headers = self._collect_response_headers(response) resp_content_type = response.get('Content-Type', '') resp_body = '' if hasattr(response, 'content'): resp_body = _safe_json_body(response.content, resp_content_type) req_h_str = json.dumps(req_headers, ensure_ascii=False) if req_headers else '' resp_h_str = json.dumps(resp_headers, ensure_ascii=False) if resp_headers else '' api_logger.info( '%s %s | user=%s | query=%s | status=%s | %dms\n' ' >>> headers: %s\n' ' >>> body: %s\n' ' <<< headers: %s\n' ' <<< body: %s', request.method, path, user_id, query, response.status_code, duration_ms, req_h_str, req_body, resp_h_str, resp_body, ) return response