"""操作日志模型。 需求 2.5.1 要求「记录用户登录、操作行为及计提参数/系数表变更,支持日志查询」。 登录行为已在 `login_logs` 中单独记录(登录失败也要留痕,且不依赖认证上下文); 本表记录**已认证用户的操作行为**,两者分工明确、互不重叠。 不继承 TimestampMixin:日志是追加写、不可变的,没有"更新时间"语义。 """ from datetime import datetime from sqlalchemy import DateTime, ForeignKey, Index, Integer, String, Text, text from sqlalchemy.orm import Mapped, mapped_column from app.models.base import BIGINT_UNSIGNED, TINYINT_TYPE, Base, mysql_table_args # 各文本列的上限。**写入方必须按此截断**,否则 MySQL 严格模式抛 1406 # (Data too long),而按"审计失败不得影响业务"的约定该异常会被吞掉 —— # 结果是整条日志静默丢失。截断值集中定义于此,供 repository 层引用。 USERNAME_MAX_LEN = 64 MODULE_MAX_LEN = 64 ACTION_MAX_LEN = 32 SUMMARY_MAX_LEN = 255 PATH_MAX_LEN = 255 IP_MAX_LEN = 45 USER_AGENT_MAX_LEN = 255 ERROR_MESSAGE_MAX_LEN = 500 # request_data 是 MySQL TEXT(上限 65535 字节)。脱敏后的 JSON 可能超限 # (如批量操作提交数千个 ID),超限同样会抛 1406,故写入方需按字节截断。 REQUEST_DATA_MAX_BYTES = 60000 class OperationLog(Base): """操作日志表。 记录原则与 `login_logs` 一致: - **请求参数必须脱敏后入库**(复用 `core.logging.scrub`),日志是最容易泄漏 密码与令牌的地方 - 用户名冗余存储而非只存 user_id —— 账号被删除后仍能追溯操作者, 故 `username` 单独建索引(与 `login_logs` 保持一致) - 写入失败不得影响业务(审计可丢,业务不可断) """ __tablename__ = "operation_logs" __table_args__ = mysql_table_args( "操作日志表", Index("idx_operation_logs_user_id", "user_id"), # 账号删除后 user_id 被置空,只能按用户名追溯,故该列必须有索引, # 否则日志表增长后查询退化为全表扫描 Index("idx_operation_logs_username", "username"), Index("idx_operation_logs_module", "module"), Index("idx_operation_logs_create_time", "create_time"), ) id: Mapped[int] = mapped_column( BIGINT_UNSIGNED, primary_key=True, autoincrement=True, comment="主键ID" ) user_id: Mapped[int | None] = mapped_column( BIGINT_UNSIGNED, ForeignKey("users.id", ondelete="SET NULL"), comment="操作人ID(账号删除后置空)", ) username: Mapped[str] = mapped_column( String(USERNAME_MAX_LEN), nullable=False, comment="操作人用户名(冗余存储便于追溯)" ) module: Mapped[str] = mapped_column( String(MODULE_MAX_LEN), nullable=False, comment="所属模块,如 用户管理" ) action: Mapped[str] = mapped_column( String(ACTION_MAX_LEN), nullable=False, comment="操作类型:新增/修改/删除/导出" ) summary: Mapped[str] = mapped_column( String(SUMMARY_MAX_LEN), nullable=False, comment="操作摘要(中文,供人阅读)" ) method: Mapped[str | None] = mapped_column(String(8), comment="HTTP 方法") path: Mapped[str | None] = mapped_column(String(PATH_MAX_LEN), comment="请求路径") ip: Mapped[str | None] = mapped_column(String(IP_MAX_LEN), comment="客户端IP(45位兼容IPv6)") user_agent: Mapped[str | None] = mapped_column( String(USER_AGENT_MAX_LEN), comment="客户端User-Agent(截断存储)" ) # 请求参数,**入库前已脱敏**;长文本故用 TEXT request_data: Mapped[str | None] = mapped_column(Text, comment="请求参数(已脱敏)") # 默认 0 而非 1:与 login_logs 保持一致,且方向更保守 —— # 写入方漏传时记为「失败」,管理员会去核查;反之漏传记为「成功」则是 # 审计漏报,且无声无息。写入方应显式传值(见 repository 层)。 success: Mapped[int] = mapped_column( TINYINT_TYPE, nullable=False, server_default="0", comment="是否成功:1成功 0失败" ) error_message: Mapped[str | None] = mapped_column( String(ERROR_MESSAGE_MAX_LEN), comment="失败原因(写入方须截断)" ) duration_ms: Mapped[int | None] = mapped_column(Integer, comment="处理耗时(毫秒)") create_time: Mapped[datetime] = mapped_column( DateTime, nullable=False, server_default=text("CURRENT_TIMESTAMP"), comment="创建时间" )