feat(system): 完成后端系统管理(审计/角色/操作日志/日志查询)

- 操作日志审计机制: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>
This commit is contained in:
liuwenxinandClaude Code committed 2026-10-10 16:28:04 +08:00
1 parent e22c4bcdc9
commit c83287819a
32 files changed
+3158 -137

No files matched your search

+105
View File
@@ -0,0 +1,105 @@
"""日志查询的请求/响应模型。
操作日志与登录日志字段不同(前者含模块/操作/请求详情,后者含失败原因),
故各自定义 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