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

+92
View File
@@ -0,0 +1,92 @@
"""日志查询模型单元测试。
守的是**时间范围校验**与**时区归一**:起始晚于结束时必须拒绝 —— 放过去
的话 SQL 条件互斥、查询恒为空,使用者会以为是"没有数据"而不是"条件写反了";
带时区的输入必须转换为本地 naive 时间 —— 不转换时 aware/naive 混用比较
抛 TypeError(穿透成 500),或偏移被驱动静默丢弃(查询窗口偏 8 小时)。
"""
from datetime import UTC, datetime
import pytest
from pydantic import ValidationError
from app.schemas.log import LoginLogQuery, OperationLogQuery
class TestTimeRange:
"""start_time ≤ end_time 校验(两类日志共用基类)。"""
def test_合法范围通过(self) -> None:
query = OperationLogQuery(
start_time=datetime(2026, 10, 1, 0, 0, 0), end_time=datetime(2026, 10, 10, 0, 0, 0)
)
assert query.start_time is not None
def test_起始晚于结束被拒(self) -> None:
with pytest.raises(ValidationError):
OperationLogQuery(start_time=datetime(2026, 10, 2), end_time=datetime(2026, 10, 1))
def test_登录日志同样校验(self) -> None:
with pytest.raises(ValidationError):
LoginLogQuery(start_time=datetime(2026, 10, 2), end_time=datetime(2026, 10, 1))
def test_边界相等通过(self) -> None:
moment = datetime(2026, 10, 1, 12, 0, 0)
assert OperationLogQuery(start_time=moment, end_time=moment).start_time == moment
def test_仅传一端不校验(self) -> None:
assert OperationLogQuery(start_time=datetime(2026, 10, 1)).end_time is None
assert OperationLogQuery(end_time=datetime(2026, 10, 1)).start_time is None
class TestQueryFields:
"""筛选字段边界。"""
def test_成功标记只能为0或1(self) -> None:
assert OperationLogQuery(success=0).success == 0
with pytest.raises(ValidationError):
OperationLogQuery(success=2)
def test_分页别名与偏移(self) -> None:
query = OperationLogQuery(pageNum=2, pageSize=50)
assert query.offset == 50
def test_操作日志筛选字段(self) -> None:
query = OperationLogQuery(module="用户管理", action="删除", username="adm")
assert (query.module, query.action, query.username) == ("用户管理", "删除", "adm")
def test_登录日志按IP精确筛选(self) -> None:
assert LoginLogQuery(ip="10.0.0.1").ip == "10.0.0.1"
class TestTimezoneNormalization:
"""时区归一。
回归背景:混合 aware/naive 比较曾抛 TypeError 穿透成 500;aware 直传
曾被驱动静默丢弃偏移(查询窗口整体偏 8 小时且无提示)。
"""
def test_utc后缀被转换为本地时间(self) -> None:
"""Z 后缀 = UTC 零点 = 东八区本地 08:00(与库列的本地时间口径对齐)。"""
query = OperationLogQuery(start_time="2026-10-01T00:00:00Z")
assert query.start_time == datetime(2026, 10, 1, 8, 0, 0)
def test_混合aware与naive不再抛比较错误(self) -> None:
query = OperationLogQuery(
start_time=datetime(2026, 10, 1, 0, 0, 0),
end_time=datetime(2026, 10, 2, 0, 0, 0, tzinfo=UTC),
)
assert query.end_time is not None
assert query.end_time.tzinfo is None # 已摘除
def test_归一后范围校验仍按本地口径生效(self) -> None:
with pytest.raises(ValidationError):
OperationLogQuery(
start_time="2026-10-02T00:00:00Z", # 本地 10-02 08:00
end_time="2026-10-01 12:00:00", # 本地 10-01 12:00,早于起始
)
def test_naive时间视为本地原样保留(self) -> None:
moment = datetime(2026, 10, 1, 12, 0, 0)
assert OperationLogQuery(start_time=moment).start_time == moment