PayrollManagementSystem/backend/app/models/operation_log.py
liuwenxin c83287819a 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>
2026-10-10 16:28:04 +08:00

100 lines
4.6 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""操作日志模型。
需求 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
METHOD_MAX_LEN = 8
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(METHOD_MAX_LEN), 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="创建时间"
)