仓储层的 list_*/count_* 此前以 keyword/status/offset/limit 四五个散参数 传参,调用点重复铺开、易漏传且不可扩展;按项目「参数 >4 封装 DTO」的 约定统一收敛为各模块的 Query 对象(如 RoleQuery、BusinessQuery), 服务层与测试调用同步调整,行为不变。 - repositories 层查询方法签名改为 (session, query),分页与筛选条件 由 Query 承载,_apply_filters 同步收口 - schemas 层补齐各模块 Query 模型(approval/business_role/report 等) - services 层调用点同步改为传 Query - tests 随之调整;新增用例:待办角色流转(财务通过后领导出现待办)、 参数预览顺序稳定 验证:全量 pytest 900 passed(11:08);单元 526 passed;ruff check 绿。 Co-Authored-By: Claude Code <noreply@anthropic.com>
85 lines
2.8 KiB
Python
85 lines
2.8 KiB
Python
"""登录日志数据访问。"""
|
||
|
||
from dataclasses import dataclass
|
||
|
||
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
|
||
|
||
|
||
@dataclass(frozen=True, slots=True)
|
||
class LoginLogEntry:
|
||
"""一条待写入的登录日志(全字段 DTO)。
|
||
|
||
与 ``OperationLogEntry`` 同款做法:写入字段平铺传递时,任何一处漏传
|
||
都只表现为日志字段缺失、不报错(success 默认 0 的语义尤其隐蔽)。
|
||
"""
|
||
|
||
user_id: int | None
|
||
username: str
|
||
success: bool
|
||
ip: str | None = None
|
||
user_agent: str | None = None
|
||
fail_reason: str | None = None
|
||
|
||
|
||
@dataclass(frozen=True, slots=True)
|
||
class LoginLogListQuery:
|
||
"""登录日志列表的查询条件(筛选 + 分页)。"""
|
||
|
||
filters: LogFilters
|
||
offset: int = 0
|
||
limit: int = 20
|
||
ip: str | None = None
|
||
|
||
|
||
def add(session: Session, entry: LoginLogEntry) -> None:
|
||
"""写入一条登录日志。只 flush 不 commit。
|
||
|
||
**绝不记录密码** —— 明文与哈希都不写。日志是最容易泄漏凭据的地方。
|
||
|
||
:param entry.user_id: 账号不存在时为 None(列本身可空)
|
||
:param entry.username: 用户名原样记录,便于账号不存在时也能追溯
|
||
"""
|
||
session.add(
|
||
LoginLog(
|
||
user_id=entry.user_id,
|
||
username=entry.username,
|
||
ip=entry.ip,
|
||
user_agent=entry.user_agent[:_USER_AGENT_MAX_LEN] if entry.user_agent else None,
|
||
success=1 if entry.success else 0,
|
||
fail_reason=entry.fail_reason,
|
||
)
|
||
)
|
||
session.flush()
|
||
|
||
|
||
def list_logs(session: Session, query: LoginLogListQuery) -> list[LoginLog]:
|
||
"""分页查询登录日志。
|
||
|
||
**按 id 倒序而非 create_time**:后者是秒级精度,同一秒内的多条记录排序
|
||
不确定,翻页会重复或遗漏(与用户列表同一理由)。
|
||
"""
|
||
stmt = _apply_filters(select(LoginLog), query)
|
||
stmt = stmt.order_by(LoginLog.id.desc()).offset(query.offset).limit(query.limit)
|
||
return list(session.execute(stmt).scalars())
|
||
|
||
|
||
def count_logs(session: Session, query: LoginLogListQuery) -> int:
|
||
"""统计符合条件的登录日志总数,供分页使用。"""
|
||
stmt = _apply_filters(select(func.count(LoginLog.id)), query)
|
||
return int(session.execute(stmt).scalar_one())
|
||
|
||
|
||
def _apply_filters(stmt: Select, query: LoginLogListQuery) -> Select:
|
||
"""应用筛选条件(公共部分复用 query_utils,与操作日志同口径)。"""
|
||
stmt = apply_log_filters(stmt, LoginLog, query.filters)
|
||
if query.ip:
|
||
stmt = stmt.where(LoginLog.ip == query.ip)
|
||
return stmt
|