mirror of
https://github.com/fastapiadmin/FastapiAdmin.git
synced 2026-09-22 21:06:32 +00:00
135 lines
5.4 KiB
Python
135 lines
5.4 KiB
Python
import json
|
|
import time
|
|
from collections.abc import Callable, Coroutine
|
|
from typing import Any, TypedDict
|
|
|
|
from fastapi import Request, Response
|
|
from fastapi.routing import APIRoute
|
|
from starlette.background import BackgroundTask
|
|
|
|
from app.config.setting import settings
|
|
from app.core.database import async_db_session
|
|
from app.core.logger import logger
|
|
from app.utils.ip_local_util import get_client_ip
|
|
|
|
_SENSITIVE_KEYS: set[str] = {
|
|
"password",
|
|
"passwd",
|
|
"old_password",
|
|
"new_password",
|
|
"confirm_password",
|
|
"captcha_key",
|
|
"token",
|
|
"access_token",
|
|
"refresh_token",
|
|
"api_key",
|
|
"apikey",
|
|
"secret",
|
|
"client_secret",
|
|
"secret_key",
|
|
"authorization",
|
|
}
|
|
_REDACTED = "******"
|
|
|
|
|
|
def _redact_sensitive(obj: Any) -> Any:
|
|
"""递归脱敏 dict/list 中的敏感字段(键名匹配不区分大小写)。"""
|
|
if isinstance(obj, dict):
|
|
return {k: (_REDACTED if str(k).lower() in _SENSITIVE_KEYS else _redact_sensitive(v)) for k, v in obj.items()}
|
|
if isinstance(obj, list):
|
|
return [_redact_sensitive(item) for item in obj]
|
|
return obj
|
|
|
|
|
|
class OperationLogRecord(TypedDict):
|
|
"""操作日志落库记录(字段与 OperationLogModel 对应,定义与消费同处)。"""
|
|
|
|
username: str
|
|
request_path: str
|
|
request_method: str
|
|
request_payload: str
|
|
response_code: int
|
|
response_json: str
|
|
process_time: str
|
|
description: str
|
|
request_ip: str
|
|
|
|
|
|
async def _write_operation_log_async(log_data: OperationLogRecord) -> None:
|
|
"""落库操作日志(BackgroundTask 中执行;失败仅告警,不影响响应)。"""
|
|
from app.modules.system.log.model import OperationLogModel # 延迟导入:core 导入期不依赖业务层(守卫不变式 3)
|
|
|
|
try:
|
|
async with async_db_session() as session, session.begin():
|
|
session.add(OperationLogModel(**log_data))
|
|
except Exception:
|
|
logger.exception("操作日志写入失败: path={}", log_data.get("request_path"))
|
|
|
|
|
|
class OperationLogRoute(APIRoute):
|
|
"""操作日志路由 — 按配置的 HTTP 方法自动记录请求/响应并后台异步写入。"""
|
|
|
|
def get_route_handler(self) -> Callable[[Request], Coroutine[Any, Any, Response]]:
|
|
original_route_handler = super().get_route_handler()
|
|
|
|
async def custom_route_handler(request: Request) -> Response:
|
|
start = time.perf_counter()
|
|
response: Response = await original_route_handler(request)
|
|
|
|
if request.method not in settings.OPERATION_RECORD_METHOD:
|
|
return response
|
|
route: APIRoute = request.scope.get("route", None)
|
|
|
|
try:
|
|
oper_param: dict[str, Any] = {}
|
|
content_type = request.headers.get("Content-Type", "")
|
|
if content_type.startswith(("multipart/form-data", "application/x-www-form-urlencoded")):
|
|
try:
|
|
form_data = await request.form()
|
|
# 过滤 UploadFile 对象并脱敏凭据字段
|
|
oper_param["form"] = {k: (_REDACTED if k.lower() in _SENSITIVE_KEYS else v) for k, v in form_data.items() if not hasattr(v, "read")}
|
|
except Exception:
|
|
oper_param["form"] = {}
|
|
else:
|
|
payload = await request.body()
|
|
if payload:
|
|
try:
|
|
oper_param["body"] = _redact_sensitive(json.loads(payload.decode()))
|
|
except (json.JSONDecodeError, UnicodeDecodeError):
|
|
oper_param["body"] = payload.decode("utf-8", errors="ignore")
|
|
|
|
if request.path_params:
|
|
oper_param["path_params"] = dict(request.path_params)
|
|
|
|
log_payload = json.dumps(oper_param, ensure_ascii=False)
|
|
if len(log_payload) > 2000:
|
|
log_payload = "请求参数过长"
|
|
|
|
is_json = "application/json" in response.headers.get("Content-Type", "")
|
|
if is_json:
|
|
# 响应体同样脱敏:登录/刷新响应含 access_token,明文入库等于 token 泄露
|
|
try:
|
|
response_data = json.dumps(_redact_sensitive(json.loads(bytes(response.body).decode())), ensure_ascii=False).encode()
|
|
except (json.JSONDecodeError, UnicodeDecodeError):
|
|
response_data = b"{}"
|
|
else:
|
|
response_data = b"{}"
|
|
|
|
log_data: OperationLogRecord = {
|
|
"username": str(getattr(getattr(request.state, "ctx", None), "user_username", None) or "unknown"),
|
|
"request_path": request.url.path,
|
|
"request_method": request.method,
|
|
"request_payload": log_payload,
|
|
"response_code": response.status_code,
|
|
"response_json": bytes(response_data).decode(),
|
|
"process_time": f"{(time.perf_counter() - start):.2f}s",
|
|
"description": (route.summary or "") if route else "",
|
|
"request_ip": get_client_ip(request),
|
|
}
|
|
response.background = BackgroundTask(_write_operation_log_async, log_data)
|
|
except Exception:
|
|
logger.warning("操作日志采集异常: {}", request.url.path, exc_info=True)
|
|
return response
|
|
|
|
return custom_route_handler
|