first commit

This commit is contained in:
2026-06-25 17:41:06 +08:00
commit b2f23a933b
370 changed files with 30526 additions and 0 deletions
+31
View File
@@ -0,0 +1,31 @@
from starlette.background import BackgroundTasks
from .ctx import CTX_BG_TASKS
class BgTasks:
"""后台任务统一管理"""
@classmethod
async def init_bg_tasks_obj(cls):
"""实例化后台任务,并设置到上下文"""
bg_tasks = BackgroundTasks()
CTX_BG_TASKS.set(bg_tasks)
@classmethod
async def get_bg_tasks_obj(cls):
"""从上下文中获取后台任务实例"""
return CTX_BG_TASKS.get()
@classmethod
async def add_task(cls, func, *args, **kwargs):
"""添加后台任务"""
bg_tasks = await cls.get_bg_tasks_obj()
bg_tasks.add_task(func, *args, **kwargs)
@classmethod
async def execute_tasks(cls):
"""执行后台任务,一般是请求结果返回之后执行"""
bg_tasks = await cls.get_bg_tasks_obj()
if bg_tasks.tasks:
await bg_tasks()
+163
View File
@@ -0,0 +1,163 @@
# core/cache.py
import json as _stdlib_json
from contextlib import asynccontextmanager
from typing import Any, AsyncGenerator, Optional, Union
import logging
logger = logging.getLogger(__name__)
from redis.asyncio import Redis
# ===== 尝试使用你的 fff,否则回退到 stdlib =====
try:
import orjson as json
except ImportError:
json = _stdlib_json # type: ignore
# ===== Redis 客户端单例(可替换为你自己的)=====
class RedisClient:
_instance: Optional["RedisClient"] = None
_redis: Optional[Redis] = None
def __new__(cls):
if cls._instance is None:
cls._instance = super().__new__(cls)
return cls._instance
async def init_redis(self, url: str = "redis://localhost:6379/0") -> None:
if self._redis is None:
self._redis = Redis.from_url(url, decode_responses=False)
@property
def client(self) -> Redis:
if self._redis is None:
raise RuntimeError("Redis not initialized. Call init_redis() first.")
return self._redis
async def close(self) -> None:
if self._redis:
await self._redis.close()
self._redis = None
redis_client = RedisClient()
# ===== FastAPI 依赖注入 =====
async def get_redis() -> Redis:
"""FastAPI 依赖:获取 Redis 客户端"""
return redis_client.client
async def invalidate_cache(key: str) -> bool:
"""
主动删除缓存键。
返回是否成功删除(Redis delete 返回被删除的 key 数量)。
"""
try:
result = await redis_client.client.delete(key)
return result > 0
except Exception as e:
logger.exception(f"Failed to invalidate cache key: {key}", exc_info=e)
return False
# ===== 缓存上下文管理器 =====
class CacheResult:
__slots__ = ("key", "hit", "value", "_to_set", "_set_called")
def __init__(self, key: str) -> None:
self.key = key
self.hit = False
self.value: Any = None
self._to_set: Any = None
self._set_called = False
def set(self, value: Any) -> None:
"""标记要缓存的值(可为 None"""
self._to_set = value
self._set_called = True
@asynccontextmanager
async def cache_if(
key: str,
ttl: int = 3600,
redis: Optional[Redis] = None,
) -> AsyncGenerator[CacheResult, None]:
"""
异步缓存上下文管理器,支持 None 值缓存(防穿透)。
Args:
key: 缓存键
ttl: 正常值缓存时间(秒)
redis: 可选 Redis 客户端(用于测试或自定义)
Usage:
async with cache_if("report:123") as cache:
if cache.hit:
return cache.value
result = await compute()
cache.set(result) # result 可为 None
"""
result = CacheResult(key)
client = redis or redis_client.client
# 尝试读缓存
try:
cached_val = await client.get(key)
if cached_val is not None:
# 解码
if cached_val == b"__NULL__":
result.hit = True
result.value = None
else:
result.hit = True
result.value = json.loads(cached_val)
except Exception:
# Redis 不可用,降级(不中断主流程)
logger.exception(f"Redis get error for key: {key}")
pass
yield result
# 写缓存(仅当调用了 set()
if result._set_called:
try:
if result._to_set is None:
val = b"__NULL__"
ex = 60 # 空值短 TTL
else:
# 注意:orjson.dumps 返回 bytesstdlib 返回 str → 统一转 bytes
serialized = json.dumps(result._to_set)
val = serialized if isinstance(serialized, bytes) else serialized.encode("utf-8")
ex = ttl
await client.setex(key, ex, val)
except Exception:
# 写缓存失败,不影响主流程
pass
# ===== 装饰器版(可选补充)=====
from functools import wraps
import asyncio
import hashlib
def cached(ttl: int = 3600):
"""函数缓存装饰器(使用 cache_if)"""
def decorator(func):
@wraps(func)
async def wrapper(*args, **kwargs):
# 生成 key(简单版,可替换为更 robust 的)
key_data = str(args) + str(sorted(kwargs.items()))
key = f"cached:{func.__name__}:{hashlib.md5(key_data.encode()).hexdigest()}"
logger.debug(f"Cache key: {key}")
async with cache_if(key, ttl) as cache:
if cache.hit:
logger.debug(f"Cache hit for key: {key}")
return cache.value
result = await func(*args, **kwargs)
cache.set(result)
return result
return wrapper
return decorator
+78
View File
@@ -0,0 +1,78 @@
from typing import Any, Callable, Dict, Generic, List, NewType, Tuple, Type, TypeVar, Union
from pydantic import BaseModel
from tortoise.expressions import Q
from tortoise.models import Model
Total = NewType("Total", int)
ModelType = TypeVar("ModelType", bound=Model)
CreateSchemaType = TypeVar("CreateSchemaType", bound=BaseModel)
UpdateSchemaType = TypeVar("UpdateSchemaType", bound=BaseModel)
class CRUDBase(Generic[ModelType, CreateSchemaType, UpdateSchemaType]):
def __init__(self, model: Type[ModelType]):
self.model = model
async def is_exist(self, **kwargs) -> bool:
return await self.model.filter(**kwargs).first()
async def all(self, search: Q = Q()) -> List[ModelType]:
return await self.model.filter(search).all()
async def get(self, id: int) -> ModelType:
return await self.model.get(id=id)
async def list(self, page: int, page_size: int, search: Q = Q(), order: list = []) -> Tuple[Total, List[ModelType]]:
query = self.model.filter(search)
return await query.count(), await query.offset((page - 1) * page_size).limit(page_size).order_by(*order)
async def create(self, obj_in: CreateSchemaType) -> ModelType:
if isinstance(obj_in, Dict):
obj_dict = obj_in
else:
obj_dict = obj_in.model_dump()
obj = self.model(**obj_dict)
await obj.save()
return obj
async def copy(self, id: int, handler: Callable[[Dict[str, Any]], Dict[str, Any]] = None) -> ModelType:
obj = await self.get(id=id)
obj_dict = await obj.to_dict()
drop_keys = ["id", "created_at", "updated_at"]
for key in drop_keys:
obj_dict.pop(key, None)
if handler:
obj_dict = handler(obj_dict)
obj = self.model(**obj_dict)
await obj.save()
return obj
async def update(self, id: int, obj_in: Union[UpdateSchemaType, Dict[str, Any]]) -> ModelType:
if isinstance(obj_in, Dict):
obj_dict = obj_in
else:
obj_dict = obj_in.model_dump(exclude_unset=True, exclude={"id"})
obj = await self.get(id=id)
obj = obj.update_from_dict(obj_dict)
await obj.save()
return obj
async def create_or_update(self, obj_in: CreateSchemaType, query_kwargs: Dict[str, Any], update_kwargs: Dict[str, Any] = None) -> ModelType:
update_kwargs = update_kwargs or {}
orm = await self.model.filter(**query_kwargs).first()
if orm:
need_update = False if update_kwargs else True
for key, value in update_kwargs.items():
if getattr(orm, key) != value:
need_update = True
break
if need_update:
return 'update', await self.update(orm.id, obj_in)
else:
return False, orm
return 'create', await self.create(obj_in)
async def remove(self, id: int) -> None:
obj = await self.get(id=id)
await obj.delete()
+17
View File
@@ -0,0 +1,17 @@
import contextvars
from starlette.background import BackgroundTasks
CTX_USER_ID: contextvars.ContextVar[int] = contextvars.ContextVar("user_id", default=0)
CTX_USER_NAME: contextvars.ContextVar[str] = contextvars.ContextVar("user_name", default="")
CTX_BG_TASKS: contextvars.ContextVar[BackgroundTasks] = contextvars.ContextVar("bg_task", default=None)
def set_ctx_weixin_user(user_id: int, user_name: str):
CTX_USER_ID.set(user_id)
CTX_USER_NAME.set(user_name)
def get_ctx_weixin_user():
return {
"user_id": CTX_USER_ID.get(),
"user_name": CTX_USER_NAME.get(),
}
+69
View File
@@ -0,0 +1,69 @@
from typing import Optional
import jwt
from fastapi import Depends, Header, HTTPException, Request
from app.core.ctx import CTX_USER_ID
from app.models import Role, User, WeixinUser
from app.settings import settings
class AuthControl:
@classmethod
async def is_authed(cls, token: str = Header(..., description="token验证")) -> Optional["User"]:
try:
if token == "dev":
user = await User.filter().first()
user_id = user.id
else:
decode_data = jwt.decode(token, settings.SECRET_KEY, algorithms=settings.JWT_ALGORITHM)
user_id = decode_data.get("user_id")
user = await User.filter(id=user_id).first()
if not user:
raise HTTPException(status_code=401, detail="Authentication failed")
CTX_USER_ID.set(int(user_id))
return user
except jwt.DecodeError:
raise HTTPException(status_code=401, detail="无效的Token")
except jwt.ExpiredSignatureError:
raise HTTPException(status_code=401, detail="登录已过期")
except Exception as e:
raise HTTPException(status_code=500, detail=f"{repr(e)}")
@classmethod
async def weixin_user(cls, request: Request) -> WeixinUser:
path = request.url.path
if path in [
"/api/v1/msg/new_order",
"/api/v1/event/feishu",
"/api/v1/msg/get_order_user_days_before",
]:
return None
token = request.headers.get("token")
user = await WeixinUser.filter(userid=token).first()
assert user, "Authentication failed"
CTX_USER_ID.set(int(user.id))
return user # 返回的是 WeixinUser 实例
class PermissionControl:
@classmethod
async def has_permission(cls, request: Request, current_user: User = Depends(AuthControl.is_authed)) -> None:
if current_user.is_superuser:
return
method = request.method
path = request.url.path
roles: list[Role] = await current_user.roles
if not roles:
raise HTTPException(status_code=403, detail="The user is not bound to a role")
apis = [await role.apis for role in roles]
permission_apis = list(set((api.method, api.path) for api in sum(apis, [])))
# path = "/api/v1/auth/userinfo"
# method = "GET"
if (method, path) not in permission_apis:
raise HTTPException(status_code=403, detail=f"Permission denied method:{method} path:{path}")
DependAuth = Depends(AuthControl.is_authed)
DependWeixinUser = Depends(AuthControl.weixin_user)
DependPermisson = Depends(PermissionControl.has_permission)
+150
View File
@@ -0,0 +1,150 @@
from fastapi.exceptions import (
HTTPException,
RequestValidationError,
ResponseValidationError,
)
from fastapi.requests import Request
from fastapi.responses import JSONResponse
from tortoise.exceptions import DoesNotExist, IntegrityError
from app.http_base import HttpResp
import logging
logger = logging.getLogger(__name__)
class SettingNotFound(Exception):
pass
class AssertException(Exception):
"""断言异常"""
def __init__(self, message: str, code: int = 1001):
self.message = message
self.code = code
super().__init__(message)
async def DoesNotExistHandle(req: Request, exc: DoesNotExist) -> JSONResponse:
"""处理对象不存在异常"""
resp = HttpResp.REQUEST_404_ERROR
content = dict(
code=resp.code,
msg=f"Object has not found, exc: {exc}, query_params: {req.query_params}",
data=None
)
logger.warning(f"Object not found: {exc}, path: {req.url.path}")
return JSONResponse(content=content, status_code=200)
async def IntegrityHandle(req: Request, exc: IntegrityError) -> JSONResponse:
"""处理数据完整性异常"""
resp = HttpResp.SYSTEM_ERROR
content = dict(
code=resp.code,
msg=f"IntegrityError: {str(exc)}",
data=None
)
logger.error(f"Integrity error: {exc}, path: {req.url.path}", exc_info=True)
return JSONResponse(content=content, status_code=200)
async def HttpExcHandle(req: Request, exc: HTTPException) -> JSONResponse:
"""处理 HTTP 异常"""
content = dict(
code=exc.status_code,
msg=exc.detail,
data=None
)
logger.warning(f"HTTP exception: {exc.status_code} - {exc.detail}, path: {req.url.path}")
return JSONResponse(content=content, status_code=200)
async def RequestValidationHandle(req: Request, exc: RequestValidationError) -> JSONResponse:
"""处理请求验证异常"""
resp = HttpResp.PARAMS_VALID_ERROR
errors = []
for error in exc.errors():
errors.append({
"loc": error["loc"],
"msg": error["msg"],
"type": error["type"]
})
content = dict(
code=resp.code,
msg=f"{resp.msg}, errors: {errors}",
data=errors
)
logger.warning(f"Request validation error: {errors}, path: {req.url.path}")
return JSONResponse(content=content, status_code=200)
async def ResponseValidationHandle(req: Request, exc: ResponseValidationError) -> JSONResponse:
"""处理响应验证异常"""
resp = HttpResp.SYSTEM_ERROR
content = dict(
code=resp.code,
msg=f"Response validation error: {str(exc)}",
data=None
)
logger.error(f"Response validation error: {exc}, path: {req.url.path}", exc_info=True)
return JSONResponse(content=content, status_code=200)
async def AssertExceptionHandle(request: Request, exc: AssertException) -> JSONResponse:
"""处理断言异常"""
resp = HttpResp.ASSERT_ARGUMENT_ERROR
content = dict(
code=exc.code,
msg=exc.message,
data=None
)
logger.warning(f"Assert exception: {exc.message}, path: {request.url.path}")
return JSONResponse(
content=content,
status_code=200
)
async def AssertionErrorHandle(request: Request, exc: AssertionError) -> JSONResponse:
"""处理 Python assert 异常"""
resp = HttpResp.ASSERT_ARGUMENT_ERROR
message = str(exc) if exc.args else resp.msg
content = dict(
code=resp.code,
msg=message,
data=None
)
logger.warning(f"Assertion error: {message}, path: {request.url.path}")
return JSONResponse(
content=content,
status_code=200
)
async def GlobalExceptionHandler(request: Request, exc: Exception) -> JSONResponse:
"""全局异常处理器"""
resp = HttpResp.SYSTEM_ERROR
content = dict(
code=resp.code,
msg=f"{resp.msg}: {str(exc)}",
data=None
)
logger.error(f"Global exception: {str(exc)}, path: {request.url.path}", exc_info=True)
return JSONResponse(
content=content,
status_code=200
)
# 便捷函数用于注册所有异常处理器
def register_exception_handlers(app):
"""注册所有异常处理器到 FastAPI 应用"""
app.add_exception_handler(DoesNotExist, DoesNotExistHandle)
app.add_exception_handler(IntegrityError, IntegrityHandle)
app.add_exception_handler(HTTPException, HttpExcHandle)
app.add_exception_handler(RequestValidationError, RequestValidationHandle)
app.add_exception_handler(ResponseValidationError, ResponseValidationHandle)
app.add_exception_handler(AssertException, AssertExceptionHandle)
app.add_exception_handler(AssertionError, AssertionErrorHandle) # 捕获 Python assert
app.add_exception_handler(Exception, GlobalExceptionHandler) # 全局异常处理器
+273
View File
@@ -0,0 +1,273 @@
import shutil
from aerich import Command
from fastapi import FastAPI
from fastapi.middleware import Middleware
from fastapi.middleware.cors import CORSMiddleware
from tortoise.expressions import Q
from app.api import api_router
from app.controllers.api import api_controller
from app.controllers.user import UserCreate, user_controller
from app.core.exceptions import register_exception_handlers
from app.log import logger
from app.models.admin import Api, Menu, Role, Dept
from app.models.automation import Task, Scenario
from app.schemas.menus import MenuType
from app.settings.config import settings
from app.core.cache import redis_client
from .middlewares import BackGroundTaskMiddleware, HttpAuditLogMiddleware
def make_middlewares():
middleware = [
Middleware(
CORSMiddleware,
allow_origins=settings.CORS_ORIGINS,
allow_credentials=settings.CORS_ALLOW_CREDENTIALS,
allow_methods=settings.CORS_ALLOW_METHODS,
allow_headers=settings.CORS_ALLOW_HEADERS,
),
Middleware(BackGroundTaskMiddleware),
Middleware(
HttpAuditLogMiddleware,
methods=["GET", "POST", "PUT", "DELETE"],
exclude_paths=[
"/api/v1/msg/new_order",
"/api/v1/weixin/jssdk-config",
"/api/v1/base/access_token",
"/docs",
"/api-docs",
"/openapi.json",
"/static/*",
"/gen/*",
],
),
]
return middleware
def mount_static_and_config_swagger(app: FastAPI):
from fastapi.staticfiles import StaticFiles
from fastapi.openapi.docs import get_swagger_ui_html
app.mount("/static", StaticFiles(directory="static"), name="静态文件")
@app.get(app.docs_url, include_in_schema=False)
async def custom_swagger_ui_html():
print('访问文档')
return get_swagger_ui_html(
openapi_url=app.openapi_url,
title=app.title,
swagger_js_url="/static/swagger-ui/swagger-ui-bundle.js",
swagger_css_url="/static/swagger-ui/swagger-ui.css"
)
def register_exceptions(app: FastAPI):
register_exception_handlers(app)
print('注册异常处理完成')
def register_routers(app: FastAPI, prefix: str = "/api"):
app.include_router(api_router, prefix=prefix)
async def init_superuser():
user = await user_controller.model.exists()
if not user:
await user_controller.create_user(
UserCreate(
username="admin",
email="admin@admin.com",
password="123456",
is_active=True,
is_superuser=True,
)
)
async def init_menus():
menus = await Menu.exists()
if not menus:
parent_menu = await Menu.create(
menu_type=MenuType.CATALOG,
name="系统管理",
path="/system",
order=1,
parent_id=0,
icon="carbon:gui-management",
is_hidden=False,
component="Layout",
keepalive=False,
redirect="/system/user",
)
children_menu = [
Menu(
menu_type=MenuType.MENU,
name="用户管理",
path="user",
order=1,
parent_id=parent_menu.id,
icon="material-symbols:person-outline-rounded",
is_hidden=False,
component="/system/user",
keepalive=False,
),
Menu(
menu_type=MenuType.MENU,
name="角色管理",
path="role",
order=2,
parent_id=parent_menu.id,
icon="carbon:user-role",
is_hidden=False,
component="/system/role",
keepalive=False,
),
Menu(
menu_type=MenuType.MENU,
name="菜单管理",
path="menu",
order=3,
parent_id=parent_menu.id,
icon="material-symbols:list-alt-outline",
is_hidden=False,
component="/system/menu",
keepalive=False,
),
Menu(
menu_type=MenuType.MENU,
name="API管理",
path="api",
order=4,
parent_id=parent_menu.id,
icon="ant-design:api-outlined",
is_hidden=False,
component="/system/api",
keepalive=False,
),
Menu(
menu_type=MenuType.MENU,
name="部门管理",
path="dept",
order=5,
parent_id=parent_menu.id,
icon="mingcute:department-line",
is_hidden=False,
component="/system/dept",
keepalive=False,
),
Menu(
menu_type=MenuType.MENU,
name="审计日志",
path="auditlog",
order=6,
parent_id=parent_menu.id,
icon="ph:clipboard-text-bold",
is_hidden=False,
component="/system/auditlog",
keepalive=False,
),
]
await Menu.bulk_create(children_menu)
await Menu.create(
menu_type=MenuType.MENU,
name="一级菜单",
path="/top-menu",
order=2,
parent_id=0,
icon="material-symbols:featured-play-list-outline",
is_hidden=False,
component="/top-menu",
keepalive=False,
redirect="",
)
async def init_apis():
apis = await api_controller.model.exists()
if not apis:
await api_controller.refresh_api()
async def init_db():
command = Command(tortoise_config=settings.TORTOISE_ORM)
try:
await command.init_db(safe=True)
except FileExistsError:
pass
await command.init()
try:
await command.migrate()
except AttributeError:
logger.warning("unable to retrieve model history from database, model history will be created from scratch")
shutil.rmtree("migrations")
await command.init_db(safe=True)
await command.upgrade(run_in_transaction=True)
async def init_roles():
roles = await Role.exists()
if not roles:
admin_role = await Role.create(
name="管理员",
desc="管理员角色",
)
user_role = await Role.create(
name="普通用户",
desc="普通用户角色",
)
await Dept.create(name="默认部门")
# 分配所有API给管理员角色
all_apis = await Api.all()
await admin_role.apis.add(*all_apis)
# 分配所有菜单给管理员和普通用户
all_menus = await Menu.all()
await admin_role.menus.add(*all_menus)
await user_role.menus.add(*all_menus)
# 为普通用户分配基本API
basic_apis = await Api.filter(Q(method__in=["GET"]) | Q(tags="基础模块"))
await user_role.apis.add(*basic_apis)
async def init_cache():
await redis_client.init_redis(settings.REDIS_URL)
async def init_task():
task = await Task.exists(title="系统待办")
if task: return
scenario = await Scenario.create(
title="系统待办",
visible=False,
trigger={},
actions=[],
enabled=False,
notes="用于为所有存量数据创建的待办任务",
)
task = await Task.create(
title="系统待办",
status='success',
ui_schema={},
source_scenario=scenario,
notes="当系统有新的待办任务时关联动作",
)
async def init_data():
await init_db()
await init_task()
await init_cache()
await init_superuser()
await init_menus()
await init_apis()
await init_roles()
async def tear_down():
await redis_client.close()
+192
View File
@@ -0,0 +1,192 @@
import json
import re
from datetime import datetime
from typing import Any, AsyncGenerator
from fastapi import FastAPI
from fastapi.responses import Response
from fastapi.routing import APIRoute
from starlette.middleware.base import BaseHTTPMiddleware, RequestResponseEndpoint
from starlette.requests import Request
from starlette.types import ASGIApp, Receive, Scope, Send
from app.core.dependency import AuthControl
from app.models.admin import AuditLog, User
from .bgtask import BgTasks
class SimpleBaseMiddleware:
def __init__(self, app: ASGIApp) -> None:
self.app = app
async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
if scope["type"] != "http":
await self.app(scope, receive, send)
return
request = Request(scope, receive=receive)
response = await self.before_request(request) or self.app
await response(request.scope, request.receive, send)
await self.after_request(request)
async def before_request(self, request: Request):
return self.app
async def after_request(self, request: Request):
return None
class BackGroundTaskMiddleware(SimpleBaseMiddleware):
async def before_request(self, request):
await BgTasks.init_bg_tasks_obj()
async def after_request(self, request):
await BgTasks.execute_tasks()
class HttpAuditLogMiddleware(BaseHTTPMiddleware):
def __init__(self, app, methods: list[str], exclude_paths: list[str]):
super().__init__(app)
self.methods = methods
self.exclude_paths = exclude_paths
self.audit_log_paths = ["/api/v1/auditlog/list"]
self.max_body_size = 1024 * 1024 # 1MB 响应体大小限制
async def get_request_args(self, request: Request) -> dict:
args = {}
# 获取查询参数
for key, value in request.query_params.items():
args[key] = value
# 判断是否为文件上传请求
content_type = request.headers.get("content-type", "").lower()
is_upload = (
request.method in {"POST", "PUT"} # 上传通常用 POST/PUT
and (
"multipart/form-data" in content_type # 标准文件上传
or "application/octet-stream" in content_type # 二进制流上传(较少见)
)
)
if is_upload:
return {}
# 获取请求体
if request.method in ["POST", "PUT", "PATCH"]:
try:
body = await request.json()
args.update(body)
except json.JSONDecodeError:
try:
body = await request.form()
args.update(body)
except Exception:
pass
return args
async def get_response_body(self, request: Request, response: Response) -> Any:
content_type = response.headers.get("content-type", "").lower()
if not content_type.startswith("application/json"):
return {"msg": "Non-JSON response (e.g., file download), skipped for audit"}
# 检查Content-Length
content_length = response.headers.get("content-length")
if content_length and int(content_length) > self.max_body_size:
return {"code": 0, "msg": "Response too large to log", "data": None}
if hasattr(response, "body"):
body = response.body
else:
body_chunks = []
async for chunk in response.body_iterator:
if not isinstance(chunk, bytes):
chunk = chunk.encode(response.charset)
body_chunks.append(chunk)
response.body_iterator = self._async_iter(body_chunks)
body = b"".join(body_chunks)
if any(request.url.path.startswith(path) for path in self.audit_log_paths):
try:
data = self.lenient_json(body)
# 只保留基本信息,去除详细的响应内容
if isinstance(data, dict):
data.pop("response_body", None)
if "data" in data and isinstance(data["data"], list):
for item in data["data"]:
item.pop("response_body", None)
return data
except Exception:
return None
return self.lenient_json(body)
def lenient_json(self, v: Any) -> Any:
if isinstance(v, (str, bytes)):
try:
return json.loads(v)
except (ValueError, TypeError):
pass
return v
async def _async_iter(self, items: list[bytes]) -> AsyncGenerator[bytes, None]:
for item in items:
yield item
async def get_request_log(self, request: Request, response: Response) -> dict:
"""
根据request和response对象获取对应的日志记录数据
"""
data: dict = {"path": request.url.path, "status": response.status_code, "method": request.method}
# 路由信息
app: FastAPI = request.app
for route in app.routes:
if (
isinstance(route, APIRoute)
and route.path_regex.match(request.url.path)
and request.method in route.methods
):
data["module"] = ",".join(route.tags)
data["summary"] = route.summary
# 获取用户信息
try:
token = request.headers.get("token")
user_obj = None
if token:
user_obj: User = await AuthControl.is_authed(token)
data["user_id"] = user_obj.id if user_obj else 0
data["username"] = user_obj.username if user_obj else ""
except Exception:
data["user_id"] = 0
data["username"] = ""
return data
async def before_request(self, request: Request):
request_args = await self.get_request_args(request)
request.state.request_args = request_args
async def after_request(self, request: Request, response: Response, process_time: int):
if request.method in self.methods:
for path in self.exclude_paths:
if re.search(path, request.url.path, re.I) is not None:
return
data: dict = await self.get_request_log(request=request, response=response)
data["response_time"] = process_time
data["request_args"] = request.state.request_args
data["response_body"] = await self.get_response_body(request, response)
await AuditLog.create(**data)
return response
async def dispatch(self, request: Request, call_next: RequestResponseEndpoint) -> Response:
start_time: datetime = datetime.now()
await self.before_request(request)
response = await call_next(request)
end_time: datetime = datetime.now()
process_time = int((end_time.timestamp() - start_time.timestamp()) * 1000)
await self.after_request(request, response, process_time)
return response