"""登录日志数据访问。""" 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