import json import time from collections.abc import Callable, Coroutine from typing import Any from fastapi import Depends, Request, Response from fastapi.routing import APIRoute from starlette.background import BackgroundTask from app.config.setting import settings from app.core.base_schema import AuthSchema from app.core.database import async_db_session from app.core.dependencies import RequireTenantWrite from app.core.logger import logger from app.utils.ip_local_util import get_client_ip _WRITE_METHODS = {"POST", "PUT", "DELETE", "PATCH"} async def _write_operation_log_async(log_data: dict) -> None: from app.api.v1.module_system.log.crud import OperationLogCRUD from app.api.v1.module_system.log.schema import OperationLogCreateSchema try: async with async_db_session() as _session, _session.begin(): auth = AuthSchema(check_data_scope=False) await OperationLogCRUD(auth, _session).create(data=OperationLogCreateSchema(**log_data)) except Exception: logger.exception("操作日志写入失败: path={}", log_data.get("request_path")) class OperationLogRoute(APIRoute): """操作日志路由 — 自动记录请求/响应并后台异步写入,自动为写操作注入租户写权限检查 根据 HTTP 方法判断: - 写方法 (POST/PUT/DELETE/PATCH):注入 RequireTenantWrite 依赖 - 读方法 (GET/HEAD/OPTIONS):不注入 RequireTenantWrite 内部会根据 auth.tenant_status 精确判断, 不会误拦只读操作(只读租户的只读路由根本不会触发写权限检查)。 """ def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) methods = getattr(self, "methods", set()) if methods & _WRITE_METHODS: if self.dependencies is None: self.dependencies = [] self.dependencies.append(Depends(RequireTenantWrite())) 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.time() 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")): form_data = await request.form() oper_param["form"] = dict(form_data.items()) else: payload = await request.body() if payload: try: oper_param["body"] = 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", "") response_data = response.body if is_json else b"{}" log_data: dict[str, Any] = { "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.time() - start):.2f}s", "description": route.summary 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