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:
1 parent
e22c4bcdc9
commit
c83287819a
32 files changed
+3158
-137
No files matched your search
@@ -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
|
||||
@@ -0,0 +1,80 @@
|
||||
"""角色管理的请求/响应模型。
|
||||
|
||||
`RoleOut` 原位于 `schemas/user.py`,因角色管理成为独立模块而迁入本文件 ——
|
||||
用户表单的角色下拉与角色管理列表共用同一响应模型。
|
||||
"""
|
||||
|
||||
import re
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field, field_validator
|
||||
|
||||
from app.core.response import PageQuery
|
||||
|
||||
# 角色编码规则:小写字母开头,后接小写字母/数字/下划线,长度 2–32(与列宽一致)。
|
||||
# 不允许大写与中文 —— 编码会出现在授权判断(`ROLE_ADMIN` 常量按编码比对)与
|
||||
# 操作日志里,保持单一风格避免大小写/全角混用造成对照困难
|
||||
ROLE_CODE_PATTERN = re.compile(r"^[a-z][a-z0-9_]{1,31}$")
|
||||
|
||||
|
||||
class RoleOut(BaseModel):
|
||||
"""角色基础响应(用户表单下拉与角色列表共用)。"""
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
id: int
|
||||
role_code: str
|
||||
role_name: str
|
||||
description: str | None = None
|
||||
sort_order: int = 0
|
||||
status: int = 1
|
||||
|
||||
|
||||
class RoleManageOut(RoleOut):
|
||||
"""角色管理列表项:额外携带绑定用户数,供前端提示删除保护的原因。"""
|
||||
|
||||
user_count: int = 0
|
||||
|
||||
|
||||
class RoleCreateIn(BaseModel):
|
||||
"""新增角色。"""
|
||||
|
||||
model_config = ConfigDict(str_strip_whitespace=True)
|
||||
|
||||
role_code: str = Field(min_length=2, max_length=32, description="角色编码,创建后不可修改")
|
||||
role_name: str = Field(min_length=1, max_length=64, description="角色名称")
|
||||
description: str | None = Field(default=None, max_length=255)
|
||||
sort_order: int = Field(default=0, ge=0, description="显示排序,越小越靠前")
|
||||
|
||||
@field_validator("role_code")
|
||||
@classmethod
|
||||
def _check_code(cls, value: str) -> str:
|
||||
if not ROLE_CODE_PATTERN.match(value):
|
||||
raise ValueError("角色编码须以小写字母开头,仅可含小写字母、数字与下划线")
|
||||
return value
|
||||
|
||||
|
||||
class RoleUpdateIn(BaseModel):
|
||||
"""编辑角色。
|
||||
|
||||
**不含 role_code** —— 编码是授权判定的锚点,修改会让既有授权语义静默改变。
|
||||
前端表单全量提交,故为全量替换语义。
|
||||
"""
|
||||
|
||||
model_config = ConfigDict(str_strip_whitespace=True)
|
||||
|
||||
role_name: str = Field(min_length=1, max_length=64, description="角色名称")
|
||||
description: str | None = Field(default=None, max_length=255)
|
||||
sort_order: int = Field(default=0, ge=0, description="显示排序,越小越靠前")
|
||||
|
||||
|
||||
class RoleStatusIn(BaseModel):
|
||||
"""启用/禁用角色。"""
|
||||
|
||||
status: int = Field(ge=0, le=1, description="1启用 0禁用")
|
||||
|
||||
|
||||
class RoleQuery(PageQuery):
|
||||
"""角色列表查询条件。"""
|
||||
|
||||
keyword: str | None = Field(default=None, max_length=64, description="编码或名称,模糊匹配")
|
||||
status: int | None = Field(default=None, ge=0, le=1, description="状态筛选")
|
||||
@@ -1,4 +1,4 @@
|
||||
"""用户与角色的请求/响应模型。
|
||||
"""用户管理的请求/响应模型(角色相关模型见 `role.py`)。
|
||||
|
||||
与 `auth.py` 的分工:`auth.py` 管登录链路(验证码、登录、当前用户),
|
||||
本模块管**用户管理**(增删改查、重置密码、启停、角色分配)。
|
||||
@@ -111,16 +111,3 @@ class UserQuery(PageQuery):
|
||||
real_name: str | None = Field(default=None, max_length=64, description="姓名,模糊匹配")
|
||||
status: int | None = Field(default=None, ge=0, le=1, description="状态筛选")
|
||||
role_code: str | None = Field(default=None, max_length=32, description="角色编码筛选")
|
||||
|
||||
|
||||
class RoleOut(BaseModel):
|
||||
"""角色响应。"""
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
id: int
|
||||
role_code: str
|
||||
role_name: str
|
||||
description: str | None = None
|
||||
sort_order: int = 0
|
||||
status: int = 1
|
||||
Reference in new issue
Block a user