Files
track/backend/alembic/versions/j1k2l3m4n5o6_add_audit_logs.py
openhands 7635802a42 feat(audit): 新增操作审计日志(表/中间件/查询接口)+ 角色常量收敛
背景:系统此前没有操作审计。task_logs 的 task_id 是 NOT NULL 外键,只能挂在
任务上,且全项目仅 4 处写入点 —— 登录、导出、产品增删改、收编完全不留痕。
需求方整理的问题清单里「无审计日志查看页」正源于此:不是没有页面,是没数据。

设计参考 MOM(KCGL) 的 audit_logs / audit_listener,但按 Track 栈做了取舍:

1) 写入时机:MOM 用 SQLAlchemy event listener + 同事务写入,优点是零侵入,
   缺点是**业务回滚时审计一起消失**,而失败/被拒的操作(越权尝试、参数错误)
   恰恰最需要留痕。Track 改为响应生成后用**独立 session** 写入:
   - 业务回滚不影响审计(已验证 422/401 失败操作同样落库)
   - 审计写入失败也不影响业务(全包裹 try/except)
   - 代价:非原子提交,响应后进程立即被 kill 可能丢一条(已注释说明取舍)

2) 采集方式:中间件自动采集写操作 + 导出/下载/打印这类「读但敏感」的 GET。
   路径段推导 module/action/target_id。不做手写埋点,因为手写必然漏 ——
   task_logs 只有 4 处写入点就是前车之鉴。

3) 增量价值:新增 request_id 字段,与 core/logging.py 的结构化日志打通,
   凭一个 ID 就能从审计记录直接跳到那一次接口日志。MOM 无此字段。

4) 敏感信息:details 经 sanitize_details 递归剔除 password/token/secret 等键;
   中间件不读请求体,登录明文密码不会落库(已断言表内无密码痕迹)。

配套改动:
- core/roles.py:角色常量与 is_admin 收敛为单一事实来源。此前同一份
  「管理员角色」规则散在 task_service、products.py 内联判断和前端
  constants/task.ts 三处,已因此发生过「移动端漏判 SUPERVISOR 误挡主管」。
  task_service 改为从 core.roles 导入同名常量,保持既有引用可用。
- core/deps.py:抽出 require_roles/require_admin 可复用依赖,替代内联判断。
- main.py:500 响应显式补 X-Request-ID 头 —— 该响应由 ServerErrorMiddleware
  生成,位于 RequestContextMiddleware 外层,中间件没机会写头。
- auth.py:登录校验前把「尝试的账号」写入 request.state,使登录事件
  (含失败登录)可归属到人,可用于追踪暴力破解。

验证:本地起 PostgreSQL 17 + 迁移后跑端到端测试,32/32 通过
(TestClient 每个请求新建事件循环,与模块级 asyncpg 连接池冲突会报
 "got Future attached to a different loop",故改用 httpx.AsyncClient +
 ASGITransport 单循环;生产 uvicorn 单循环无此问题)。
2026-09-21 02:23:19 +00:00

82 lines
3.7 KiB
Python
Raw 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.

"""add_audit_logs
Revision ID: j1k2l3m4n5o6
Revises: i1j2k3l4m5n6
Create Date: 2026-09-21
操作审计日志表audit_logs
--------------------------
新增一张独立的审计表,用于记录 task_logs 覆盖不到的操作:
登录、导出、产品增删改、收口、权限/配置变更等与单个任务无关的动作。
为什么另起一张表而不复用 task_logs
task_logs.task_id 是 NOT NULL 外键,只能挂在任务上,无法表达「张三导出了
产品清单」这类动作;且缺少来源 IP / UA / 结果状态等审计必需字段。
存量数据无需回填(本表从上线时刻开始记录)。
"""
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
from sqlalchemy.dialects import postgresql
revision: str = "j1k2l3m4n5o6"
down_revision: Union[str, None] = "i1j2k3l4m5n6"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.create_table(
"audit_logs",
sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True, nullable=False),
# 操作人
sa.Column("user_id", sa.String(64), nullable=True, comment="操作人账号(逻辑外键→MOM)"),
sa.Column("display_name", sa.String(100), nullable=True, comment="操作人显示名"),
sa.Column("role", sa.String(50), nullable=True, comment="操作时角色快照"),
# 业务语义
sa.Column("action", sa.String(50), nullable=False, comment="动作"),
sa.Column("module", sa.String(50), nullable=False, comment="业务模块"),
sa.Column("target_type", sa.String(50), nullable=True),
sa.Column("target_id", sa.String(100), nullable=True),
sa.Column("target_name", sa.String(200), nullable=True),
sa.Column("details", postgresql.JSONB(), nullable=True, comment="变更详情"),
# 请求上下文
sa.Column("ip_address", sa.String(50), nullable=True),
sa.Column("user_agent", sa.String(500), nullable=True),
sa.Column("method", sa.String(10), nullable=True),
sa.Column("url", sa.String(500), nullable=True),
sa.Column("status_code", sa.Integer(), nullable=True),
sa.Column("error_message", sa.Text(), nullable=True),
# 与结构化日志对账
sa.Column("request_id", sa.String(64), nullable=True),
sa.Column(
"created_at",
sa.DateTime(timezone=True),
nullable=False,
server_default=sa.func.now(),
),
)
# 索引:按审计页最常用的检索维度建
op.create_index("ix_audit_logs_created_at", "audit_logs", ["created_at"])
op.create_index("ix_audit_logs_user_id", "audit_logs", ["user_id"])
op.create_index("ix_audit_logs_module", "audit_logs", ["module"])
op.create_index("ix_audit_logs_action", "audit_logs", ["action"])
op.create_index("ix_audit_logs_target_id", "audit_logs", ["target_id"])
op.create_index("ix_audit_logs_request_id", "audit_logs", ["request_id"])
# 组合索引:审计页默认「按时间倒序 + 按模块/动作过滤」
op.create_index("ix_audit_logs_module_created", "audit_logs", ["module", "created_at"])
def downgrade() -> None:
op.drop_index("ix_audit_logs_module_created", table_name="audit_logs")
op.drop_index("ix_audit_logs_request_id", table_name="audit_logs")
op.drop_index("ix_audit_logs_target_id", table_name="audit_logs")
op.drop_index("ix_audit_logs_action", table_name="audit_logs")
op.drop_index("ix_audit_logs_module", table_name="audit_logs")
op.drop_index("ix_audit_logs_user_id", table_name="audit_logs")
op.drop_index("ix_audit_logs_created_at", table_name="audit_logs")
op.drop_table("audit_logs")