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:
81
backend/alembic/versions/j1k2l3m4n5o6_add_audit_logs.py
Normal file
81
backend/alembic/versions/j1k2l3m4n5o6_add_audit_logs.py
Normal 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")
|
||||
99
backend/app/api/v1/endpoints/audit.py
Normal file
99
backend/app/api/v1/endpoints/audit.py
Normal 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()],
|
||||
)
|
||||
@ -1,5 +1,5 @@
|
||||
"""认证 API — 对接 MOM sys_user + 双 Token 刷新"""
|
||||
from fastapi import APIRouter, Depends
|
||||
from fastapi import APIRouter, Depends, Request
|
||||
from app.schemas.user import (
|
||||
LoginRequest,
|
||||
LoginResponse,
|
||||
@ -13,8 +13,13 @@ router = APIRouter(prefix="/auth", tags=["认证"])
|
||||
|
||||
|
||||
@router.post("/login", response_model=LoginResponse)
|
||||
def login_endpoint(data: LoginRequest):
|
||||
def login_endpoint(data: LoginRequest, request: Request):
|
||||
"""登录 — 验证 MOM sys_user 表,返回 Access + Refresh 双 Token"""
|
||||
# 登录请求本身尚未认证,中间件拿不到操作人。但「谁在尝试登录、失败了多少次」
|
||||
# 恰恰是审计里最该有的信息,所以在校验之前就把尝试的账号写进 state:
|
||||
# 登录失败时同样留痕,且能按账号追踪暴力破解。
|
||||
# 注意:绝不把 data.password 写进 state / 审计,密码不落库。
|
||||
request.state.audit_user = data.username
|
||||
return login(data.username, data.password)
|
||||
|
||||
|
||||
|
||||
@ -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.external_products import router as external_products_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()
|
||||
|
||||
@ -37,3 +38,4 @@ api_router.include_router(holidays_router)
|
||||
api_router.include_router(webhooks_router)
|
||||
api_router.include_router(external_products_router)
|
||||
api_router.include_router(screen_router)
|
||||
api_router.include_router(audit_router)
|
||||
|
||||
195
backend/app/core/audit_middleware.py
Normal file
195
backend/app/core/audit_middleware.py
Normal 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
35
backend/app/core/deps.py
Normal 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
31
backend/app/core/roles.py
Normal 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
|
||||
@ -4,6 +4,7 @@ from fastapi import FastAPI, Request
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from fastapi.responses import JSONResponse
|
||||
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.logging import request_id_var, setup_logging
|
||||
from app.core.middleware import RequestContextMiddleware
|
||||
@ -72,8 +73,11 @@ app.add_middleware(
|
||||
expose_headers=["X-Request-ID"],
|
||||
)
|
||||
|
||||
# Starlette 的 add_middleware 是「后添加者在外层」,故 RequestContextMiddleware
|
||||
# 最后注册 → 最先进入请求,才能覆盖包括 CORS 预检在内的全部请求并回写 X-Request-ID
|
||||
# Starlette 的 add_middleware 是「后添加者在外层」。执行顺序(由外到内):
|
||||
# RequestContextMiddleware -> AuditMiddleware -> CORS -> 路由
|
||||
# AuditMiddleware 必须在 RequestContext 内层,才能读到后者写入 request.state
|
||||
# 的 request_id,从而把审计记录与结构化日志对上。
|
||||
app.add_middleware(AuditMiddleware)
|
||||
app.add_middleware(RequestContextMiddleware)
|
||||
|
||||
|
||||
@ -96,6 +100,10 @@ async def unhandled_exception_handler(request: Request, exc: Exception) -> JSONR
|
||||
return JSONResponse(
|
||||
status_code=500,
|
||||
content={"detail": "服务器内部错误", "request_id": request_id},
|
||||
# 该响应由 ServerErrorMiddleware(位于 RequestContextMiddleware 外层)
|
||||
# 生成,中间件没机会再往响应头写 X-Request-ID,故在此显式补上,
|
||||
# 保证报障时前端从响应头就能拿到可对账的 ID。
|
||||
headers={"X-Request-ID": request_id} if request_id else None,
|
||||
)
|
||||
|
||||
|
||||
|
||||
@ -8,6 +8,7 @@ from app.models.notification import Notification
|
||||
from app.models.app_version import AppVersion
|
||||
from app.models.message import ProductMessage
|
||||
from app.models.holiday import Holiday
|
||||
from app.models.audit_log import AuditLog
|
||||
__all__ = [
|
||||
"Base",
|
||||
"ProductionOrder",
|
||||
@ -19,4 +20,5 @@ __all__ = [
|
||||
"AppVersion",
|
||||
"ProductMessage",
|
||||
"Holiday",
|
||||
"AuditLog",
|
||||
]
|
||||
|
||||
83
backend/app/models/audit_log.py
Normal file
83
backend/app/models/audit_log.py
Normal 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}>"
|
||||
57
backend/app/schemas/audit.py
Normal file
57
backend/app/schemas/audit.py
Normal 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]
|
||||
200
backend/app/services/audit_service.py
Normal file
200
backend/app/services/audit_service.py
Normal 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
|
||||
@ -22,6 +22,7 @@ from app.core.lifecycle import (
|
||||
)
|
||||
from app.models.product import Product
|
||||
from app.models.task_log import TaskLog
|
||||
from app.core.roles import ADMIN_ROLES
|
||||
from app.schemas.task import (
|
||||
TaskCreate,
|
||||
TaskUpdate,
|
||||
@ -42,7 +43,8 @@ from app.schemas.task import (
|
||||
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(
|
||||
|
||||
Reference in New Issue
Block a user