- 操作日志审计机制: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>
78 lines
2.3 KiB
Python
78 lines
2.3 KiB
Python
"""登录日志数据访问。"""
|
||
|
||
from sqlalchemy import Select, func, select
|
||
from sqlalchemy.orm import Session
|
||
|
||
from app.models import LoginLog
|
||
from app.repositories.query_utils import LogFilters, apply_log_filters
|
||
|
||
# user_agent 列宽,超长需截断否则插入报错
|
||
_USER_AGENT_MAX_LEN = 255
|
||
|
||
|
||
def add(
|
||
session: Session,
|
||
*,
|
||
user_id: int | None,
|
||
username: str,
|
||
ip: str | None,
|
||
user_agent: str | None,
|
||
success: bool,
|
||
fail_reason: str | None = None,
|
||
) -> None:
|
||
"""写入一条登录日志。只 flush 不 commit。
|
||
|
||
**绝不记录密码** —— 明文与哈希都不写。日志是最容易泄漏凭据的地方。
|
||
|
||
:param user_id: 账号不存在时传 None(列本身可空)
|
||
:param username: 用户名原样记录,便于账号不存在时也能追溯
|
||
"""
|
||
session.add(
|
||
LoginLog(
|
||
user_id=user_id,
|
||
username=username,
|
||
ip=ip,
|
||
user_agent=user_agent[:_USER_AGENT_MAX_LEN] if user_agent else None,
|
||
success=1 if success else 0,
|
||
fail_reason=fail_reason,
|
||
)
|
||
)
|
||
session.flush()
|
||
|
||
|
||
def list_logs(
|
||
session: Session,
|
||
*,
|
||
offset: int,
|
||
limit: int,
|
||
filters: LogFilters,
|
||
ip: str | None = None,
|
||
) -> list[LoginLog]:
|
||
"""分页查询登录日志。
|
||
|
||
**按 id 倒序而非 create_time**:后者是秒级精度,同一秒内的多条记录排序
|
||
不确定,翻页会重复或遗漏(与用户列表同一理由)。
|
||
"""
|
||
stmt = _apply_filters(select(LoginLog), filters=filters, ip=ip)
|
||
stmt = stmt.order_by(LoginLog.id.desc()).offset(offset).limit(limit)
|
||
return list(session.execute(stmt).scalars())
|
||
|
||
|
||
def count_logs(
|
||
session: Session,
|
||
*,
|
||
filters: LogFilters,
|
||
ip: str | None = None,
|
||
) -> int:
|
||
"""统计符合条件的登录日志总数,供分页使用。"""
|
||
stmt = _apply_filters(select(func.count(LoginLog.id)), filters=filters, ip=ip)
|
||
return int(session.execute(stmt).scalar_one())
|
||
|
||
|
||
def _apply_filters(stmt: Select, *, filters: LogFilters, ip: str | None) -> Select:
|
||
"""应用筛选条件(公共部分复用 query_utils,与操作日志同口径)。"""
|
||
stmt = apply_log_filters(stmt, LoginLog, filters)
|
||
if ip:
|
||
stmt = stmt.where(LoginLog.ip == ip)
|
||
return stmt
|