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 单循环无此问题)。
This commit is contained in:
openhands
2026-09-21 02:23:19 +00:00
parent 04eb87b091
commit 7635802a42
13 changed files with 805 additions and 5 deletions

View File

@ -0,0 +1,81 @@
"""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")

View File

@ -0,0 +1,99 @@
"""审计日志 API —— 查看系统操作审计记录
与 MOM(KCGL) /audit/logs 的接口保持同构的筛选维度(操作人/模块/动作/目标/
时间区间),便于两端运维习惯统一;额外提供 request_id 筛选,可凭它直接跳到
结构化日志里的那一次请求。
"""
from __future__ import annotations
from datetime import datetime, time, timedelta
from fastapi import APIRouter, Depends, Query
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.database import get_db
from app.core.deps import require_admin
from app.core.time_utils import BEIJING_TZ
from app.schemas.audit import (
AuditLogListResponse,
AuditLogResponse,
AuditOption,
AuditOptionsResponse,
)
from app.services import audit_service
from app.services.audit_service import ACTION_LABELS, MODULE_LABELS
router = APIRouter(prefix="/audit", tags=["审计日志"])
def _parse_day(value: str | None, *, end_of_day: bool = False) -> datetime | None:
"""解析 YYYY-MM-DD 为北京时间。
结束日期取次日 00:00 作为上界(配合 < 判断)—— 直接取当天 23:59:59 会
漏掉该秒内的记录,是日期区间筛选最常见的差一错误。
"""
if not value:
return None
try:
day = datetime.strptime(value, "%Y-%m-%d").date()
except ValueError:
return None
if end_of_day:
return datetime.combine(day + timedelta(days=1), time.min, tzinfo=BEIJING_TZ)
return datetime.combine(day, time.min, tzinfo=BEIJING_TZ)
@router.get("/logs", response_model=AuditLogListResponse)
async def get_audit_logs(
user_id: str | None = Query(None, description="操作人账号(模糊匹配)"),
module: str | None = Query(None, description="业务模块"),
action: str | None = Query(None, description="动作类型"),
target_id: str | None = Query(None, description="目标ID"),
request_id: str | None = Query(None, description="请求ID与接口日志对账"),
status_code: int | None = Query(None, description="响应状态码"),
start_date: str | None = Query(None, description="起始日期 YYYY-MM-DD"),
end_date: str | None = Query(None, description="结束日期 YYYY-MM-DD含当天"),
page: int = Query(1, ge=1),
page_size: int = Query(50, ge=1, le=200),
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(require_admin),
) -> AuditLogListResponse:
"""审计日志分页查询(按时间倒序)"""
start = _parse_day(start_date)
# 结束日期用「次日 00:00」作为开区间上界避免漏掉当天最后几条
end_exclusive = _parse_day(end_date, end_of_day=True)
rows, total = await audit_service.list_audit_logs(
db,
user_id=user_id,
module=module,
action=action,
target_id=target_id,
request_id=request_id,
status_code=status_code,
start=start,
end=end_exclusive - timedelta(microseconds=1) if end_exclusive else None,
skip=(page - 1) * page_size,
limit=page_size,
)
items = []
for row in rows:
item = AuditLogResponse.model_validate(row)
# 中文标签由服务端补,避免前端为每个枚举再维护一份映射
item.module_label = MODULE_LABELS.get(row.module, row.module)
item.action_label = ACTION_LABELS.get(row.action, row.action)
items.append(item)
return AuditLogListResponse(items=items, total=total)
@router.get("/options", response_model=AuditOptionsResponse)
async def get_audit_options(
current_user: dict = Depends(require_admin),
) -> AuditOptionsResponse:
"""筛选项:模块与动作的中文下拉"""
return AuditOptionsResponse(
modules=[AuditOption(value=k, label=v) for k, v in MODULE_LABELS.items()],
actions=[AuditOption(value=k, label=v) for k, v in ACTION_LABELS.items()],
)

View File

@ -1,5 +1,5 @@
"""认证 API — 对接 MOM sys_user + 双 Token 刷新""" """认证 API — 对接 MOM sys_user + 双 Token 刷新"""
from fastapi import APIRouter, Depends from fastapi import APIRouter, Depends, Request
from app.schemas.user import ( from app.schemas.user import (
LoginRequest, LoginRequest,
LoginResponse, LoginResponse,
@ -13,8 +13,13 @@ router = APIRouter(prefix="/auth", tags=["认证"])
@router.post("/login", response_model=LoginResponse) @router.post("/login", response_model=LoginResponse)
def login_endpoint(data: LoginRequest): def login_endpoint(data: LoginRequest, request: Request):
"""登录 — 验证 MOM sys_user 表,返回 Access + Refresh 双 Token""" """登录 — 验证 MOM sys_user 表,返回 Access + Refresh 双 Token"""
# 登录请求本身尚未认证,中间件拿不到操作人。但「谁在尝试登录、失败了多少次」
# 恰恰是审计里最该有的信息,所以在校验之前就把尝试的账号写进 state
# 登录失败时同样留痕,且能按账号追踪暴力破解。
# 注意:绝不把 data.password 写进 state / 审计,密码不落库。
request.state.audit_user = data.username
return login(data.username, data.password) return login(data.username, data.password)

View File

@ -17,6 +17,7 @@ from app.api.v1.endpoints.holidays import router as holidays_router
from app.api.v1.endpoints.webhooks import router as webhooks_router from app.api.v1.endpoints.webhooks import router as webhooks_router
from app.api.v1.endpoints.external_products import router as external_products_router from app.api.v1.endpoints.external_products import router as external_products_router
from app.api.v1.endpoints.screen import router as screen_router from app.api.v1.endpoints.screen import router as screen_router
from app.api.v1.endpoints.audit import router as audit_router
api_router = APIRouter() api_router = APIRouter()
@ -37,3 +38,4 @@ api_router.include_router(holidays_router)
api_router.include_router(webhooks_router) api_router.include_router(webhooks_router)
api_router.include_router(external_products_router) api_router.include_router(external_products_router)
api_router.include_router(screen_router) api_router.include_router(screen_router)
api_router.include_router(audit_router)

View File

@ -0,0 +1,195 @@
"""审计采集中间件
在响应生成后,把「谁 / 何时 / 从哪来 / 调了哪个接口 / 做了什么 / 结果如何」
落进 audit_logs。
为什么用中间件自动采集,而不是在每个业务函数里手写 record_audit
------------------------------------------------------------------
1. 手写必然漏。新加的端点很容易忘记补审计,而审计的价值恰恰建立在「完整」上。
现状可佐证task_logs 全项目只有 4 处写入点,凡是不挂在任务上的动作
(登录、导出、改产品)全都没有留痕。
2. 中间件能拿到业务函数拿不到的事实:真实来源 IP、UA、最终状态码、
以及与结构化日志对齐的 request_id。
3. 业务语义module / target由路径推导不如手写精确但对「谁动了什么」
的追责场景已经够用;关键动作后续可再调 record_audit 补 details 做增强。
采集范围
--------
- 所有写操作POST/PUT/PATCH/DELETE
- 少数**读但敏感**的操作:导出、下载、打印(本项目 GET /people-history/export
就是导出,只按方法过滤会漏掉)
明确不采集GET /health*、/docs、/openapi.json —— 探针与文档的噪声没有审计价值。
"""
from __future__ import annotations
import logging
from fastapi import Request
from starlette.middleware.base import BaseHTTPMiddleware, RequestResponseEndpoint
from starlette.responses import Response
from app.services.audit_service import record_audit
logger = logging.getLogger("track.audit")
# 写操作一律采集
_MUTATING_METHODS = frozenset({"POST", "PUT", "PATCH", "DELETE"})
# 读操作里需要留痕的(导出/下载/打印属于「读」,但把数据带出了系统)
_SENSITIVE_READ_KEYWORDS = frozenset({"export", "download", "print"})
# 永久忽略的路径前缀
_IGNORED_PREFIXES = ("/health", "/docs", "/redoc", "/openapi.json")
# 路径段 → 审计模块
_PATH_MODULE: dict[str, str] = {
"products": "product",
"tasks": "task",
"orders": "order",
"records": "record",
"print": "print",
"materials": "material",
"users": "user",
"upload": "upload",
"notifications": "notification",
"app-version": "app",
"analytics": "analytics",
"dashboard": "dashboard",
"holidays": "holiday",
"screen": "screen",
"webhooks": "external",
"external": "external",
"audit": "audit",
"auth": "auth",
}
# 路径段 → 动作(优先于按 HTTP 方法推断)
_SEGMENT_ACTION: dict[str, str] = {
"login": "login",
"logout": "logout",
"refresh": "refresh",
"export": "export",
"download": "export",
"print": "print",
"upload": "upload",
"finalize": "finalize",
"receive": "receive",
"transfer": "transfer",
"reject": "reject",
"recall": "recall",
"spawn": "spawn",
"complete": "complete",
"end": "end",
}
_METHOD_ACTION: dict[str, str] = {
"POST": "create",
"PUT": "update",
"PATCH": "update",
"DELETE": "delete",
"GET": "read",
}
# 不可能是业务 ID 的路径段,避免把动作词误当成 target_id
_NON_ID_SEGMENTS = frozenset(
set(_SEGMENT_ACTION) | {"api", "v1", "me", "options", "export", "lookup", "batch"}
)
def _derive_module_and_action(path: str, method: str) -> tuple[str, str, str | None]:
"""由请求路径与 HTTP 方法推导 (module, action, target_id)"""
parts = [p for p in path.split("/") if p]
module = "other"
module_idx = -1
for i, seg in enumerate(parts):
if seg in _PATH_MODULE:
module = _PATH_MODULE[seg]
module_idx = i
break
action = None
for seg in reversed(parts):
if seg in _SEGMENT_ACTION:
action = _SEGMENT_ACTION[seg]
break
if action is None:
action = _METHOD_ACTION.get(method, method.lower())
target_id = None
if module_idx >= 0 and module_idx + 1 < len(parts):
candidate = parts[module_idx + 1]
if candidate not in _NON_ID_SEGMENTS:
target_id = candidate
return module, action, target_id
class AuditMiddleware(BaseHTTPMiddleware):
"""写操作审计采集。
必须注册在 RequestContextMiddleware **内层**,因为它依赖后者写入
request.state 的 request_id 才能与结构化日志对账。
"""
def _should_audit(self, request: Request) -> bool:
path = request.url.path
if path.startswith(_IGNORED_PREFIXES):
return False
if request.method in _MUTATING_METHODS:
return True
if request.method == "GET":
lowered = path.lower()
return any(kw in lowered for kw in _SENSITIVE_READ_KEYWORDS)
return False
async def dispatch(
self, request: Request, call_next: RequestResponseEndpoint
) -> Response:
if not self._should_audit(request):
return await call_next(request)
status_code = 500
error_message: str | None = None
try:
response = await call_next(request)
status_code = response.status_code
return response
except Exception as exc:
# 异常最终由 ServerErrorMiddleware 转成 500这里先标记
# 保证「失败的操作也有审计」——这正是选用独立 session 的目的
error_message = f"{type(exc).__name__}: {exc}"[:1000]
raise
finally:
await self._write(request, status_code, error_message)
async def _write(
self, request: Request, status_code: int, error_message: str | None
) -> None:
try:
module, action, target_id = _derive_module_and_action(
request.url.path, request.method
)
client = request.client
await record_audit(
action=action,
module=module,
user_id=getattr(request.state, "audit_user", None),
display_name=getattr(request.state, "audit_display_name", None),
role=getattr(request.state, "audit_role", None),
target_type=module,
target_id=target_id,
# 对产品而言路径里的 ID 就是身份证号,本身即人可读的标识
target_name=target_id if module == "product" else None,
ip_address=client.host if client else None,
user_agent=request.headers.get("user-agent"),
method=request.method,
url=request.url.path,
status_code=status_code,
error_message=error_message,
request_id=getattr(request.state, "request_id", None),
)
except Exception:
# record_audit 内部已兜底;这里再兜一层,确保审计绝不冒泡成 500
logger.exception("审计采集失败(已忽略)")

35
backend/app/core/deps.py Normal file
View File

@ -0,0 +1,35 @@
"""通用 FastAPI 依赖"""
from __future__ import annotations
from fastapi import Depends, HTTPException, status
from app.core.roles import ADMIN_ROLES
from app.services.auth_service import get_current_user
def require_roles(*roles: str):
"""生成「限定角色」依赖,避免同一个内联判断被复制到每个端点。
用法::
@router.get("/x")
async def x(current_user: dict = Depends(require_admin)):
...
失败一律 403 且不透露允许的角色集合(避免给探测者提供线索)。
"""
allowed = frozenset(roles)
async def _guard(current_user: dict = Depends(get_current_user)) -> dict:
if (current_user or {}).get("role") not in allowed:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="当前角色无权访问该接口",
)
return current_user
return _guard
# 审计日志等高权限接口复用同一实例
require_admin = require_roles(*ADMIN_ROLES)

31
backend/app/core/roles.py Normal file
View File

@ -0,0 +1,31 @@
"""角色定义与管理员判定 —— 单一事实来源
背景:角色字符串此前散落在至少三处 —— task_service.ADMIN_ROLES、
products.py 的内联判断、以及前端 constants/task.ts。同一份规则抄多份的后果
已经发生过:前端 constants/task.ts:233 的注释记录了一次「移动端只判了
SUPER_ADMIN、漏了 SUPERVISOR导致主管被误挡」的事故。
本模块把**角色常量与管理员判定**先收敛到一处,供后端统一引用。
完整的「角色 × 权限点」可配置矩阵是后续工作;但任何推进都应从这里出发,
不要再新增第四份副本。
"""
from __future__ import annotations
SUPER_ADMIN = "SUPER_ADMIN"
SUPERVISOR = "SUPERVISOR"
# 注意MOM 登录返回的默认角色是小写 operator见 auth_service.login
OPERATOR = "OPERATOR"
# 管理员角色:可执行收口、审计查看等高权限动作
ADMIN_ROLES: frozenset[str] = frozenset({SUPER_ADMIN, SUPERVISOR})
ROLE_LABELS: dict[str, str] = {
SUPER_ADMIN: "超级管理员",
SUPERVISOR: "主管",
OPERATOR: "操作员",
}
def is_admin(role: str | None) -> bool:
"""role 为 None / 未知值一律视为无权限fail-closed不做兜底放行"""
return role in ADMIN_ROLES

View File

@ -4,6 +4,7 @@ from fastapi import FastAPI, Request
from fastapi.middleware.cors import CORSMiddleware from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import JSONResponse from fastapi.responses import JSONResponse
from app.core.config import settings from app.core.config import settings
from app.core.audit_middleware import AuditMiddleware
from app.core.health import router as health_router from app.core.health import router as health_router
from app.core.logging import request_id_var, setup_logging from app.core.logging import request_id_var, setup_logging
from app.core.middleware import RequestContextMiddleware from app.core.middleware import RequestContextMiddleware
@ -72,8 +73,11 @@ app.add_middleware(
expose_headers=["X-Request-ID"], expose_headers=["X-Request-ID"],
) )
# Starlette 的 add_middleware 是「后添加者在外层」,故 RequestContextMiddleware # Starlette 的 add_middleware 是「后添加者在外层」。执行顺序(由外到内):
# 最后注册 → 最先进入请求,才能覆盖包括 CORS 预检在内的全部请求并回写 X-Request-ID # RequestContextMiddleware -> AuditMiddleware -> CORS -> 路由
# AuditMiddleware 必须在 RequestContext 内层,才能读到后者写入 request.state
# 的 request_id从而把审计记录与结构化日志对上。
app.add_middleware(AuditMiddleware)
app.add_middleware(RequestContextMiddleware) app.add_middleware(RequestContextMiddleware)
@ -96,6 +100,10 @@ async def unhandled_exception_handler(request: Request, exc: Exception) -> JSONR
return JSONResponse( return JSONResponse(
status_code=500, status_code=500,
content={"detail": "服务器内部错误", "request_id": request_id}, content={"detail": "服务器内部错误", "request_id": request_id},
# 该响应由 ServerErrorMiddleware位于 RequestContextMiddleware 外层)
# 生成,中间件没机会再往响应头写 X-Request-ID故在此显式补上
# 保证报障时前端从响应头就能拿到可对账的 ID。
headers={"X-Request-ID": request_id} if request_id else None,
) )

View File

@ -8,6 +8,7 @@ from app.models.notification import Notification
from app.models.app_version import AppVersion from app.models.app_version import AppVersion
from app.models.message import ProductMessage from app.models.message import ProductMessage
from app.models.holiday import Holiday from app.models.holiday import Holiday
from app.models.audit_log import AuditLog
__all__ = [ __all__ = [
"Base", "Base",
"ProductionOrder", "ProductionOrder",
@ -19,4 +20,5 @@ __all__ = [
"AppVersion", "AppVersion",
"ProductMessage", "ProductMessage",
"Holiday", "Holiday",
"AuditLog",
] ]

View File

@ -0,0 +1,83 @@
"""操作审计日志模型
设计参考 MOM(KCGL) 的 audit_logs但按 Track 的技术栈与诉求做了取舍:
- 主键用 UUID与库内其它表一致而非 MOM 的自增 int。
- 增加 request_id与 core/logging.py 的结构化日志打通 —— 凭一个 ID 就能把
「接口访问日志」和「审计记录」对上排障时不用再猜。MOM 无此字段。
- 保留 module / action / target_* 的业务语义,使审计能按业务维度检索,
而不是只能按时间翻。
- 绝不记录请求体:登录等接口 body 含明文密码,一旦落库就成了长期泄露面。
与既有 task_logs 的分工task_logs 是「任务流转轨迹」(有 task_id 非空约束,
只能挂在任务上,供流转树渲染);本表是「操作审计」,覆盖登录、导出、
产品增删改、权限变更等与单个任务无关的动作,且额外记录来源 IP / UA / 耗时结果。
"""
import uuid
from datetime import datetime
from sqlalchemy import DateTime, Integer, String, Text
from sqlalchemy.dialects.postgresql import JSONB, UUID
from sqlalchemy.orm import Mapped, mapped_column
from app.models.base import Base
from app.core.time_utils import get_beijing_time
class AuditLog(Base):
__tablename__ = "audit_logs"
id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), primary_key=True, default=uuid.uuid4,
)
# ---- 操作人(逻辑外键 → MOM sys_user仅存账号无物理约束----
user_id: Mapped[str | None] = mapped_column(
String(64), nullable=True, index=True, comment="操作人账号(逻辑外键→MOM)",
)
display_name: Mapped[str | None] = mapped_column(
String(100), nullable=True, comment="操作人显示名",
)
role: Mapped[str | None] = mapped_column(
String(50), nullable=True, comment="操作时角色快照",
)
# ---- 业务语义 ----
action: Mapped[str] = mapped_column(
String(50), nullable=False, index=True, comment="动作: create/update/delete/export/login/...",
)
module: Mapped[str] = mapped_column(
String(50), nullable=False, index=True, comment="业务模块: product/task/order/auth/print/...",
)
target_type: Mapped[str | None] = mapped_column(
String(50), nullable=True, comment="目标类型(表名或实体名)",
)
target_id: Mapped[str | None] = mapped_column(
String(100), nullable=True, index=True, comment="目标ID",
)
target_name: Mapped[str | None] = mapped_column(
String(200), nullable=True, comment="目标显示名(如产品身份证/工单号)",
)
details: Mapped[dict | None] = mapped_column(
JSONB, nullable=True, comment="变更详情 {old:{}, new:{}};禁止写入密码等敏感字段",
)
# ---- 请求上下文(由中间件自动填充)----
ip_address: Mapped[str | None] = mapped_column(String(50), nullable=True, comment="来源IP")
user_agent: Mapped[str | None] = mapped_column(String(500), nullable=True, comment="浏览器UA")
method: Mapped[str | None] = mapped_column(String(10), nullable=True, comment="HTTP方法")
url: Mapped[str | None] = mapped_column(String(500), nullable=True, comment="请求路径")
status_code: Mapped[int | None] = mapped_column(Integer, nullable=True, comment="响应状态码")
error_message: Mapped[str | None] = mapped_column(Text, nullable=True, comment="错误信息(如有)")
# ---- 与结构化日志对账用 ----
request_id: Mapped[str | None] = mapped_column(
String(64), nullable=True, index=True, comment="关联 core/logging 的 request_id",
)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), default=get_beijing_time, index=True, comment="操作时间",
)
def __repr__(self) -> str:
return f"<AuditLog {self.action} {self.module} by {self.user_id}>"

View File

@ -0,0 +1,57 @@
"""审计日志 Pydantic Schema"""
from __future__ import annotations
import uuid
from datetime import datetime
from pydantic import BaseModel
class AuditLogResponse(BaseModel):
"""单条审计记录"""
id: uuid.UUID
user_id: str | None = None
display_name: str | None = None
role: str | None = None
action: str
action_label: str | None = None # 服务端补的中文标签,避免前端各处硬编码
module: str
module_label: str | None = None
target_type: str | None = None
target_id: str | None = None
target_name: str | None = None
details: dict | None = None
ip_address: str | None = None
user_agent: str | None = None
method: str | None = None
url: str | None = None
status_code: int | None = None
error_message: str | None = None
# 与结构化日志对账用:拿着它就能捞到对应的接口日志
request_id: str | None = None
created_at: datetime
model_config = {"from_attributes": True}
class AuditLogListResponse(BaseModel):
"""审计日志分页列表"""
items: list[AuditLogResponse]
total: int
class AuditOption(BaseModel):
"""筛选项value/label 结构,直接喂给前端下拉)"""
value: str
label: str
class AuditOptionsResponse(BaseModel):
"""筛选项集合"""
modules: list[AuditOption]
actions: list[AuditOption]

View File

@ -0,0 +1,200 @@
"""审计服务 — 写入与检索
写入方案的取舍(与 MOM/KCGL 不同,理由如下)
--------------------------------------------------
MOM 用 SQLAlchemy event listener + **同事务**写入:优点是全自动、业务代码零改动;
缺点是业务事务回滚时审计记录一起被回滚掉 —— 而失败/被拒的操作恰恰是最需要
留痕的(比如越权尝试、参数错误导致的 4xx
Track 改为:响应生成后,用**独立 session** 写入审计。
- 业务回滚不影响审计,失败操作照样留痕
- 审计写入失败也不影响业务(全包裹 try/except仅记日志
- 代价:审计与业务不是原子提交,极端情况(响应后进程立即被 kill可能丢一条。
对内部系统的操作审计,这个取舍划算。
"""
from __future__ import annotations
import logging
import uuid
from datetime import datetime
from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.database import AsyncSessionLocal
from app.models.audit_log import AuditLog
logger = logging.getLogger("track.audit")
# 绝不落库的敏感字段名(命中即替换为 ***
# 登录请求体含明文密码,一旦进审计表就成了长期泄露面
_SENSITIVE_KEYS = frozenset(
{"password", "passwd", "pwd", "token", "access_token", "refresh_token",
"secret", "api_key", "authorization", "password_hash"}
)
# 模块 / 动作 的中文标签(前端下拉与列表展示用)
MODULE_LABELS: dict[str, str] = {
"auth": "认证登录",
"product": "产品管理",
"task": "任务流转",
"order": "订单管理",
"record": "任务记录",
"print": "标签打印",
"material": "物料",
"user": "用户",
"notification": "消息通知",
"upload": "文件上传",
"dashboard": "看板统计",
"analytics": "效能分析",
"screen": "数据大屏",
"holiday": "节假日配置",
"app": "App版本",
"external": "外部系统对接",
"audit": "审计日志",
"other": "其它",
}
ACTION_LABELS: dict[str, str] = {
"create": "新增",
"update": "修改",
"delete": "删除",
"read": "查询",
"export": "导出",
"login": "登录",
"logout": "登出",
"refresh": "刷新令牌",
"print": "打印",
"upload": "上传",
"finalize": "收口",
"receive": "接收",
"transfer": "转交",
"reject": "驳回",
"recall": "撤回",
"spawn": "派发",
"end": "结束分支",
"complete": "完结",
}
def sanitize_details(details: dict | None) -> dict | None:
"""递归剔除敏感字段,避免密码/令牌落库"""
if not details:
return details
def _clean(value):
if isinstance(value, dict):
return {
k: ("***" if str(k).lower() in _SENSITIVE_KEYS else _clean(v))
for k, v in value.items()
}
if isinstance(value, list):
return [_clean(v) for v in value]
return value
return _clean(details)
async def record_audit(
*,
action: str,
module: str,
user_id: str | None = None,
display_name: str | None = None,
role: str | None = None,
target_type: str | None = None,
target_id: str | None = None,
target_name: str | None = None,
details: dict | None = None,
ip_address: str | None = None,
user_agent: str | None = None,
method: str | None = None,
url: str | None = None,
status_code: int | None = None,
error_message: str | None = None,
request_id: str | None = None,
) -> None:
"""写入一条审计记录。**绝不抛异常**:审计失败不能影响业务。"""
try:
async with AsyncSessionLocal() as session:
session.add(
AuditLog(
id=uuid.uuid4(),
user_id=user_id,
display_name=display_name,
role=role,
action=action,
module=module,
target_type=target_type,
target_id=str(target_id) if target_id is not None else None,
target_name=target_name,
details=sanitize_details(details),
ip_address=ip_address,
user_agent=user_agent[:500] if user_agent else None,
method=method,
url=url[:500] if url else None,
status_code=status_code,
error_message=error_message,
request_id=request_id,
)
)
await session.commit()
except Exception:
# 用 exception 级别但吞掉异常:保证调用方业务流程不受影响
logger.exception(
"审计写入失败(已忽略,不影响业务)",
extra={"extra_fields": {"action": action, "module": module, "url": url}},
)
async def list_audit_logs(
db: AsyncSession,
*,
user_id: str | None = None,
module: str | None = None,
action: str | None = None,
target_id: str | None = None,
request_id: str | None = None,
status_code: int | None = None,
start: datetime | None = None,
end: datetime | None = None,
skip: int = 0,
limit: int = 50,
) -> tuple[list[AuditLog], int]:
"""审计日志检索(按时间倒序)。返回 (当前页, 真实总数)。
真实总数走独立 COUNT —— 前端分页器依赖它,不能用 len(当前页)。
"""
filters = []
if user_id:
filters.append(AuditLog.user_id.ilike(f"%{user_id}%"))
if module:
filters.append(AuditLog.module == module)
if action:
filters.append(AuditLog.action == action)
if target_id:
filters.append(AuditLog.target_id == target_id)
if request_id:
filters.append(AuditLog.request_id == request_id)
if status_code is not None:
filters.append(AuditLog.status_code == status_code)
if start:
filters.append(AuditLog.created_at >= start)
if end:
filters.append(AuditLog.created_at <= end)
total = await db.scalar(
select(func.count()).select_from(AuditLog).where(*filters)
) or 0
rows = (
await db.execute(
select(AuditLog)
.where(*filters)
.order_by(AuditLog.created_at.desc())
.offset(skip)
.limit(limit)
)
).scalars().all()
return list(rows), total

View File

@ -22,6 +22,7 @@ from app.core.lifecycle import (
) )
from app.models.product import Product from app.models.product import Product
from app.models.task_log import TaskLog from app.models.task_log import TaskLog
from app.core.roles import ADMIN_ROLES
from app.schemas.task import ( from app.schemas.task import (
TaskCreate, TaskCreate,
TaskUpdate, TaskUpdate,
@ -42,7 +43,8 @@ from app.schemas.task import (
VIRTUAL_WAREHOUSE = "virtual_warehouse" VIRTUAL_WAREHOUSE = "virtual_warehouse"
# 管理员/主管角色白名单 — 拥有上帝视角操作权限 # 管理员/主管角色白名单 — 拥有上帝视角操作权限
ADMIN_ROLES = {"SUPER_ADMIN", "SUPERVISOR"} # 定义已收敛到 app.core.roles单一事实来源本模块继续以同名导出
# 兼容 products.py 等处 `from app.services.task_service import ADMIN_ROLES` 的既有引用
async def _recalc_product_location( async def _recalc_product_location(