- 操作日志审计机制:audit 上下文(成功与失败都记、独立提交、操作人标量 固化、commit 后查询前置约定),用户/角色全部写操作接入;request_data 按键名的结构化脱敏 + UTF-8 字节边界截断 - 角色管理:CRUD 与启停;admin 禁删禁停、有绑定禁删(FOR UPDATE 锁行)、 编码创建后不可改、停用角色不可再分配 - 日志查询:操作/登录两端点,列表与详情两段式(详情含已脱敏请求参数); 时间参数统一转本地 naive(修混用时区 500 与偏移丢弃) - 用户管理:补齐 reset_password 操作人、编辑空串归一 NULL、并发唯一键兜底 400 - 测试 301 例全绿(单元 223 / 集成 78) Co-Authored-By: Claude Code <noreply@anthropic.com>
106 lines
3.7 KiB
Python
106 lines
3.7 KiB
Python
"""日志查询的请求/响应模型。
|
||
|
||
操作日志与登录日志字段不同(前者含模块/操作/请求详情,后者含失败原因),
|
||
故各自定义 Query 与 Out,仅共用「用户名 + 结果 + 时间范围」的筛选基类。
|
||
"""
|
||
|
||
from datetime import datetime
|
||
|
||
from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator
|
||
|
||
from app.core.response import PageQuery
|
||
|
||
|
||
class LogQueryBase(PageQuery):
|
||
"""日志公共查询条件:模糊用户名 + 结果 + 时间范围。"""
|
||
|
||
username: str | None = Field(default=None, max_length=64, description="用户名,模糊匹配")
|
||
success: int | None = Field(default=None, ge=0, le=1, description="1成功 0失败")
|
||
start_time: datetime | None = Field(default=None, description="起始时间(含)")
|
||
end_time: datetime | None = Field(default=None, description="结束时间(含)")
|
||
|
||
@field_validator("start_time", "end_time")
|
||
@classmethod
|
||
def _to_local_naive(cls, value: datetime | None) -> datetime | None:
|
||
"""统一为本地时区的 naive 时间。
|
||
|
||
库列存本地时间(容器 TZ=Asia/Shanghai),而 API 接受 ISO 8601
|
||
(可带 ``Z`` 等时区后缀)。不归一时有两种故障:aware 与 naive
|
||
混用比较抛 TypeError(穿透成 500);aware 直接透传给驱动会
|
||
**静默丢弃偏移**(查询窗口整体偏 8 小时且无任何提示)。
|
||
aware 先转系统本地时区、再摘除 tzinfo 是唯一正确姿势;
|
||
naive 视为本地时间原样保留。
|
||
"""
|
||
if value is not None and value.tzinfo is not None:
|
||
return value.astimezone().replace(tzinfo=None)
|
||
return value
|
||
|
||
@model_validator(mode="after")
|
||
def _check_time_range(self) -> "LogQueryBase":
|
||
if self.start_time and self.end_time and self.start_time > self.end_time:
|
||
raise ValueError("起始时间不能晚于结束时间")
|
||
return self
|
||
|
||
|
||
class OperationLogQuery(LogQueryBase):
|
||
"""操作日志查询条件。"""
|
||
|
||
module: str | None = Field(default=None, max_length=64, description="所属模块,精确匹配")
|
||
action: str | None = Field(default=None, max_length=32, description="操作类型,精确匹配")
|
||
|
||
|
||
class OperationLogOut(BaseModel):
|
||
"""操作日志列表项。
|
||
|
||
**不含 request_data**(TEXT,单行最大 60000 字节):列表页默认不展示它,
|
||
随列表返回会让翻页负担随日志表增长线性放大;展开详情走
|
||
`GET /operation-logs/{id}`(`OperationLogDetailOut`),前端按需加载。
|
||
"""
|
||
|
||
model_config = ConfigDict(from_attributes=True)
|
||
|
||
id: int
|
||
user_id: int | None = None
|
||
username: str
|
||
module: str
|
||
action: str
|
||
summary: str
|
||
method: str | None = None
|
||
path: str | None = None
|
||
ip: str | None = None
|
||
user_agent: str | None = None
|
||
success: int = 0
|
||
error_message: str | None = None
|
||
duration_ms: int | None = None
|
||
create_time: datetime | None = None
|
||
|
||
|
||
class OperationLogDetailOut(OperationLogOut):
|
||
"""操作日志详情:列表字段 + 请求参数。
|
||
|
||
`request_data` 入库时已经结构化 + 文本两层脱敏,可直接展示。
|
||
"""
|
||
|
||
request_data: str | None = None
|
||
|
||
|
||
class LoginLogQuery(LogQueryBase):
|
||
"""登录日志查询条件。"""
|
||
|
||
ip: str | None = Field(default=None, max_length=45, description="客户端IP,精确匹配")
|
||
|
||
|
||
class LoginLogOut(BaseModel):
|
||
"""登录日志响应。"""
|
||
|
||
model_config = ConfigDict(from_attributes=True)
|
||
|
||
id: int
|
||
user_id: int | None = None
|
||
username: str
|
||
ip: str | None = None
|
||
user_agent: str | None = None
|
||
success: int = 0
|
||
fail_reason: str | None = None
|
||
create_time: datetime | None = None
|