Files
medical_training/config/middleware.py
T

142 lines
4.5 KiB
Python

"""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/',
'/api/ping/', '/api/testmysql/',
)
# 请求体/响应体最大记录长度(字符)
_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'<multipart form data, {len(body_bytes)} bytes>'
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'<file: {f.name}, {f.size} bytes>'
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