142 lines
4.5 KiB
Python
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
|