需求 2.5.1 要求记录用户管理操作,先落表结构: - operation_logs 表:冗余 username 保证账号删除后仍可追溯, user_id 外键 ON DELETE SET NULL,success 默认 '0' 与 login_logs 口径一致 - 截断常量(USERNAME/ERROR_MESSAGE/REQUEST_DATA)集中定义,供写入方引用 - 修复既有 V1 迁移与迁移模板的 downgrade:索引是外键唯一支撑索引, 先 drop_index 会报 errno 1553,改为只 drop_table - 同步 db/migration/V2 与 init-dev.sql,迁移版本号更新为 a45a8087831b Co-Authored-By: Claude Code <noreply@anthropic.com>
99 lines
4.6 KiB
Python
99 lines
4.6 KiB
Python
"""操作日志模型。
|
||
|
||
需求 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="创建时间"
|
||
)
|