Skip to content

Commit 627208b

Browse files
authored
Add operation log related interfaces (#92)
* Add operation log related interfaces * Update to native ASGI middleware * add the opera model class to the __init__.py * Executable code collation * Reply to the access middleware * Using the request extension params in the login log * Fix the whitelist list * Fix username resolution
1 parent 9dce49d commit 627208b

15 files changed

+340
-26
lines changed

backend/app/api/routers.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
from backend.app.api.v1.api import router as api_router
1212
from backend.app.api.v1.config import router as config_router
1313
from backend.app.api.v1.login_log import router as login_log_router
14+
from backend.app.api.v1.opera_log import router as opera_log_router
1415
from backend.app.api.v1.task_demo import router as task_demo_router
1516

1617
v1 = APIRouter(prefix='/v1')
@@ -24,4 +25,5 @@
2425
v1.include_router(api_router, prefix='/apis', tags=['API管理'])
2526
v1.include_router(config_router, prefix='/configs', tags=['系统配置'])
2627
v1.include_router(login_log_router, prefix='/login_logs', tags=['登录日志管理'])
28+
v1.include_router(opera_log_router, prefix='/opera_logs', tags=['操作日志管理'])
2729
v1.include_router(task_demo_router, prefix='/tasks', tags=['任务管理'])

backend/app/api/v1/opera_log.py

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
#!/usr/bin/env python3
2+
# -*- coding: utf-8 -*-
3+
from typing import Annotated
4+
5+
from fastapi import APIRouter, Query
6+
7+
from backend.app.common.casbin_rbac import DependsRBAC
8+
from backend.app.common.jwt import DependsJwtAuth
9+
from backend.app.common.pagination import PageDepends, paging_data
10+
from backend.app.common.response.response_schema import response_base
11+
from backend.app.database.db_mysql import CurrentSession
12+
from backend.app.schemas.opera_log import GetAllOperaLog
13+
from backend.app.services.opera_log_service import OperaLogService
14+
15+
router = APIRouter()
16+
17+
18+
@router.get('', summary='(模糊条件)分页获取操作日志', dependencies=[DependsJwtAuth, PageDepends])
19+
async def get_all_opera_logs(
20+
db: CurrentSession,
21+
username: Annotated[str | None, Query()] = None,
22+
status: Annotated[bool | None, Query()] = None,
23+
ipaddr: Annotated[str | None, Query()] = None,
24+
):
25+
log_select = await OperaLogService.get_select(username=username, status=status, ipaddr=ipaddr)
26+
page_data = await paging_data(db, log_select, GetAllOperaLog)
27+
return response_base.success(data=page_data)
28+
29+
30+
@router.delete('', summary='(批量)删除操作日志', dependencies=[DependsRBAC])
31+
async def delete_opera_log(pk: Annotated[list[int], Query(...)]):
32+
count = await OperaLogService.delete(pk)
33+
if count > 0:
34+
return response_base.success()
35+
return response_base.fail()
36+
37+
38+
@router.delete('/all', summary='清空操作日志', dependencies=[DependsRBAC])
39+
async def delete_all_opera_logs():
40+
count = await OperaLogService.delete_all()
41+
if count > 0:
42+
return response_base.success()
43+
return response_base.fail()

backend/app/core/conf.py

Lines changed: 12 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -96,10 +96,18 @@ def validator_api_url(cls, values):
9696
# Casbin
9797
CASBIN_RBAC_MODEL_NAME: str = 'rbac_model.conf'
9898
CASBIN_EXCLUDE: list[dict[str, str], dict[str, str]] = [
99-
{'method': 'POST', 'path': '/api/v1/auth/swagger_login'},
100-
{'method': 'POST', 'path': '/api/v1/auth/login'},
101-
{'method': 'POST', 'path': '/api/v1/auth/register'},
102-
{'method': 'POST', 'path': '/api/v1/auth/password/reset'},
99+
{'method': 'POST', 'path': '/v1/auth/swagger_login'},
100+
{'method': 'POST', 'path': '/v1/auth/login'},
101+
{'method': 'POST', 'path': '/v1/auth/register'},
102+
{'method': 'POST', 'path': '/v1/auth/password/reset'},
103+
]
104+
105+
# Opera log
106+
OPERA_LOG_EXCLUDE: list[str] = [
107+
DOCS_URL,
108+
REDOCS_URL,
109+
OPENAPI_URL,
110+
'/v1/auth/swagger_login',
103111
]
104112

105113
class Config:

backend/app/core/registrar.py

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
from backend.app.common.task import scheduler
1414
from backend.app.core.conf import settings
1515
from backend.app.database.db_mysql import create_table
16+
from backend.app.middleware.opera_log_middleware import OperaLogMiddleware
1617
from backend.app.middleware.jwt_auth_middleware import JwtAuthMiddleware
1718
from backend.app.utils.health_check import ensure_unique_route_names
1819
from backend.app.utils.openapi import simplify_operation_ids
@@ -91,16 +92,25 @@ def register_static_file(app: FastAPI):
9192

9293

9394
def register_middleware(app: FastAPI):
95+
"""
96+
中间件,执行顺序从下往上
97+
98+
:param app:
99+
:return:
100+
"""
94101
# Gzip
95102
if settings.MIDDLEWARE_GZIP:
96103
from fastapi.middleware.gzip import GZipMiddleware
97104

98105
app.add_middleware(GZipMiddleware)
99-
# Api access logs
106+
# Access log
107+
# TODO: opera log 中间件完全可行时将被删除
100108
if settings.MIDDLEWARE_ACCESS:
101109
from backend.app.middleware.access_middleware import AccessMiddleware
102110

103111
app.add_middleware(AccessMiddleware)
112+
# Opera log
113+
app.add_middleware(OperaLogMiddleware)
104114
# JWT auth: Always open
105115
app.add_middleware(
106116
AuthenticationMiddleware, backend=JwtAuthMiddleware(), on_error=JwtAuthMiddleware.auth_exception_handler

backend/app/crud/crud_login_log.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,9 @@
1111

1212

1313
class CRUDLoginLog(CRUDBase[LoginLog, CreateLoginLog, UpdateLoginLog]):
14-
async def get_all(self, username: str = None, status: bool = None, ipaddr: str = None) -> Select:
14+
async def get_all(
15+
self, username: str | None = None, status: bool | None = None, ipaddr: str | None = None
16+
) -> Select:
1517
se = select(self.model).order_by(desc(self.model.create_time))
1618
where_list = []
1719
if username:

backend/app/crud/crud_opera_log.py

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
#!/usr/bin/env python3
2+
# -*- coding: utf-8 -*-
3+
from typing import NoReturn
4+
5+
from sqlalchemy import select, desc, and_, delete, Select
6+
from sqlalchemy.ext.asyncio import AsyncSession
7+
8+
from backend.app.crud.base import CRUDBase
9+
from backend.app.models import OperaLog
10+
from backend.app.schemas.opera_log import CreateOperaLog, UpdateOperaLog
11+
12+
13+
class CRUDOperaLogDao(CRUDBase[OperaLog, CreateOperaLog, UpdateOperaLog]):
14+
async def get_all(self, username: str | None = None, status: bool | None = None, ipaddr: str | None = None) -> Select:
15+
se = select(self.model).order_by(desc(self.model.create_time))
16+
where_list = []
17+
if username:
18+
where_list.append(self.model.username.like(f'%{username}%'))
19+
if status is not None:
20+
where_list.append(self.model.status == status)
21+
if ipaddr:
22+
where_list.append(self.model.ipaddr.like(f'%{ipaddr}%'))
23+
if where_list:
24+
se = se.where(and_(*where_list))
25+
return se
26+
27+
async def create(self, db: AsyncSession, obj_in: CreateOperaLog) -> NoReturn:
28+
await self.create_(db, obj_in)
29+
30+
async def delete(self, db: AsyncSession, pk: list[int]) -> int:
31+
logs = await db.execute(delete(self.model).where(self.model.id.in_(pk)))
32+
return logs.rowcount
33+
34+
async def delete_all(self, db: AsyncSession) -> int:
35+
logs = await db.execute(delete(self.model))
36+
return logs.rowcount
37+
38+
39+
OperaLogDao: CRUDOperaLogDao = CRUDOperaLogDao(OperaLog)

backend/app/middleware/access_middleware.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,15 +3,15 @@
33
from datetime import datetime
44

55
from fastapi import Request, Response
6-
from starlette.middleware.base import BaseHTTPMiddleware
6+
from starlette.middleware.base import BaseHTTPMiddleware, RequestResponseEndpoint
77

88
from backend.app.common.log import log
99

1010

1111
class AccessMiddleware(BaseHTTPMiddleware):
1212
"""记录请求日志中间件"""
1313

14-
async def dispatch(self, request: Request, call_next) -> Response:
14+
async def dispatch(self, request: Request, call_next: RequestResponseEndpoint) -> Response:
1515
start_time = datetime.now()
1616
response = await call_next(request)
1717
end_time = datetime.now()

backend/app/middleware/jwt_auth_middleware.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,4 +52,6 @@ async def authenticate(self, request: Request):
5252

5353
raise _AuthenticationError(msg=traceback.format_exc() if settings.ENVIRONMENT == 'dev' else None)
5454

55+
# 请注意,此返回使用非标准模式,所以在认证通过时,将丢失某些标准特性
56+
# 标准返回模式请查看:https://www.starlette.io/authentication/
5557
return auth, user
Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,117 @@
1+
#!/usr/bin/env python3
2+
# -*- coding: utf-8 -*-
3+
from datetime import datetime
4+
from typing import Any
5+
6+
from starlette.background import BackgroundTask
7+
from starlette.requests import Request
8+
from starlette.types import ASGIApp, Scope, Receive, Send
9+
from user_agents import parse
10+
11+
from backend.app.common.log import log
12+
from backend.app.core.conf import settings
13+
from backend.app.schemas.opera_log import CreateOperaLog
14+
from backend.app.services.opera_log_service import OperaLogService
15+
from backend.app.utils import request_parse
16+
17+
18+
class OperaLogMiddleware:
19+
"""操作日志中间件"""
20+
21+
def __init__(self, app: ASGIApp):
22+
self.app = app
23+
24+
async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
25+
if scope['type'] != 'http':
26+
await self.app(scope, receive, send)
27+
return
28+
29+
request = Request(scope=scope, receive=receive)
30+
31+
# 排除记录白名单
32+
path = request.url.path
33+
if path in settings.OPERA_LOG_EXCLUDE:
34+
await self.app(scope, receive, send)
35+
return
36+
37+
# 请求信息解析
38+
ip = await request_parse.get_request_ip(request)
39+
user_agent = request.headers.get('User-Agent')
40+
user_agent_parsed = parse(user_agent)
41+
os = user_agent_parsed.get_os()
42+
browser = user_agent_parsed.get_browser()
43+
if settings.LOCATION_PARSE == 'online':
44+
location = await request_parse.get_location_online(ip, user_agent)
45+
elif settings.LOCATION_PARSE == 'offline':
46+
location = request_parse.get_location_offline(ip)
47+
else:
48+
location = '未知'
49+
try:
50+
# 此信息依赖于 jwt 中间件
51+
username = request.user.username
52+
except AttributeError:
53+
username = None
54+
method = request.method
55+
args = dict(request.query_params)
56+
# TODO: 注释说明,详见 https://github.com/fastapi-practices/fastapi_best_architecture/pull/92
57+
# form_data = await request.form()
58+
# if len(form_data) > 0:
59+
# args = json.dumps(
60+
# args.update({k: v.filename if isinstance(v, UploadFile) else v for k, v in form_data.items()}),
61+
# ensure_ascii=False,
62+
# )
63+
# else:
64+
# body = await request.body()
65+
# if body:
66+
# json_data = await request.json()
67+
# args = json.dumps(args.update(json_data), ensure_ascii=False)
68+
args = str(args) if len(args) > 0 else None
69+
70+
# 设置附加请求信息
71+
request.state.ip = ip
72+
request.state.location = location
73+
request.state.os = os
74+
request.state.browser = browser
75+
76+
# 预置响应信息
77+
code: int = 200
78+
msg: str = 'Success'
79+
status: bool = True
80+
err: Any = None
81+
82+
# 执行请求
83+
start_time = datetime.now()
84+
try:
85+
await self.app(request.scope, request.receive, send)
86+
except Exception as e:
87+
log.exception(e)
88+
code = getattr(e, 'code', 500)
89+
msg = getattr(e, 'msg', 'Internal Server Error')
90+
status = False
91+
err = e
92+
end_time = datetime.now()
93+
summary = request.scope.get('route').summary
94+
title = summary if summary != '' else request.scope.get('route').summary
95+
cost_time = (end_time - start_time).total_seconds() / 1000.0
96+
97+
# 日志创建
98+
opera_log_in = CreateOperaLog(
99+
username=username,
100+
method=method,
101+
title=title,
102+
path=path,
103+
ipaddr=ip,
104+
location=location,
105+
args=args,
106+
status=status,
107+
code=code,
108+
msg=msg,
109+
cost_time=cost_time,
110+
opera_time=start_time,
111+
)
112+
back = BackgroundTask(OperaLogService.create, opera_log_in)
113+
await back()
114+
115+
# 错误抛出
116+
if err:
117+
raise err from None

backend/app/models/__init__.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,3 +12,4 @@
1212
from backend.app.models.sys_role import Role
1313
from backend.app.models.sys_user import User
1414
from backend.app.models.sys_login_log import LoginLog
15+
from backend.app.models.sys_opera_log import OperaLog

backend/app/models/sys_login_log.py

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -14,13 +14,13 @@ class LoginLog(DataClassBase):
1414
__tablename__ = 'sys_login_log'
1515

1616
id: Mapped[id_key] = mapped_column(init=False)
17-
user_uuid: Mapped[str] = mapped_column(String(50), nullable=False, comment='用户UUID')
18-
username: Mapped[str] = mapped_column(String(20), nullable=False, comment='用户名')
17+
user_uuid: Mapped[str] = mapped_column(String(50), comment='用户UUID')
18+
username: Mapped[str] = mapped_column(String(20), comment='用户名')
1919
status: Mapped[bool] = mapped_column(insert_default=0, comment='登录状态(0失败 1成功)')
20-
ipaddr: Mapped[str] = mapped_column(String(50), nullable=False, comment='登录IP地址')
21-
location: Mapped[str] = mapped_column(String(255), nullable=False, comment='归属地')
22-
browser: Mapped[str] = mapped_column(String(255), nullable=False, comment='浏览器')
23-
os: Mapped[str] = mapped_column(String(255), nullable=False, comment='操作系统')
24-
msg: Mapped[str] = mapped_column(String(255), nullable=False, comment='提示消息')
25-
login_time: Mapped[datetime] = mapped_column(nullable=False, comment='登录时间')
20+
ipaddr: Mapped[str] = mapped_column(String(50), comment='登录IP地址')
21+
location: Mapped[str] = mapped_column(String(50), comment='归属地')
22+
browser: Mapped[str] = mapped_column(String(50), comment='浏览器')
23+
os: Mapped[str] = mapped_column(String(50), comment='操作系统')
24+
msg: Mapped[str] = mapped_column(String(255), comment='提示消息')
25+
login_time: Mapped[datetime] = mapped_column(comment='登录时间')
2626
create_time: Mapped[datetime] = mapped_column(init=False, default=func.now(), comment='创建时间')

backend/app/models/sys_opera_log.py

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
#!/usr/bin/env python3
2+
# -*- coding: utf-8 -*-
3+
from datetime import datetime
4+
5+
from sqlalchemy import String, func
6+
from sqlalchemy.dialects.mysql import JSON
7+
from sqlalchemy.orm import Mapped, mapped_column
8+
9+
from backend.app.database.base_class import DataClassBase, id_key
10+
11+
12+
class OperaLog(DataClassBase):
13+
"""操作日志表"""
14+
15+
__tablename__ = 'sys_opera_log'
16+
17+
id: Mapped[id_key] = mapped_column(init=False)
18+
username: Mapped[str | None] = mapped_column(String(20), comment='用户名')
19+
method: Mapped[str] = mapped_column(String(20), comment='请求类型')
20+
title: Mapped[str] = mapped_column(String(255), comment='操作模块')
21+
path: Mapped[str] = mapped_column(String(500), comment='请求路径')
22+
ipaddr: Mapped[str] = mapped_column(String(50), comment='IP地址')
23+
location: Mapped[str] = mapped_column(String(50), comment='归属地')
24+
args: Mapped[str | None] = mapped_column(JSON(), comment='请求参数')
25+
status: Mapped[bool] = mapped_column(comment='操作状态(0异常 1正常)')
26+
code: Mapped[int] = mapped_column(insert_default=200, comment='操作状态码')
27+
msg: Mapped[str | None] = mapped_column(String(255), comment='提示消息')
28+
cost_time: Mapped[float] = mapped_column(insert_default=0.0, comment='请求耗时ms')
29+
opera_time: Mapped[datetime] = mapped_column(comment='操作时间')
30+
create_time: Mapped[datetime] = mapped_column(init=False, default=func.now(), comment='创建时间')

0 commit comments

Comments
 (0)