背景:系统此前没有操作审计。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 单循环无此问题)。
114 lines
4.2 KiB
Python
114 lines
4.2 KiB
Python
import logging
|
||
from contextlib import asynccontextmanager
|
||
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
|
||
from app.api.v1.router import api_router
|
||
|
||
# 日志必须在任何模块开始产日志之前配置好,故放模块顶层而非 lifespan 内
|
||
setup_logging(level=settings.LOG_LEVEL, json_output=settings.LOG_JSON)
|
||
|
||
logger = logging.getLogger("track.main")
|
||
|
||
|
||
def _init_error_tracking() -> None:
|
||
"""可选错误追踪:未配置 DSN,或未安装 sentry-sdk 时静默跳过"""
|
||
if not settings.SENTRY_DSN:
|
||
return
|
||
try:
|
||
import sentry_sdk
|
||
except ImportError:
|
||
logger.warning(
|
||
"已配置 SENTRY_DSN 但未安装 sentry-sdk,错误追踪未启用;"
|
||
"需要时执行 pip install sentry-sdk"
|
||
)
|
||
return
|
||
sentry_sdk.init(
|
||
dsn=settings.SENTRY_DSN,
|
||
traces_sample_rate=settings.SENTRY_TRACES_SAMPLE_RATE,
|
||
environment="production" if not settings.DEBUG else "development",
|
||
release=settings.APP_VERSION,
|
||
)
|
||
logger.info("错误追踪已启用 (Sentry)")
|
||
|
||
|
||
@asynccontextmanager
|
||
async def lifespan(app: FastAPI):
|
||
"""应用生命周期:启动时初始化连接,关闭时释放资源"""
|
||
_init_error_tracking()
|
||
logger.info(
|
||
"服务启动",
|
||
extra={
|
||
"extra_fields": {
|
||
"version": settings.APP_VERSION,
|
||
"debug": settings.DEBUG,
|
||
"cors_origins": settings.CORS_ORIGINS_LIST,
|
||
}
|
||
},
|
||
)
|
||
yield
|
||
logger.info("服务关闭")
|
||
|
||
|
||
app = FastAPI(
|
||
title="Track Production API",
|
||
description="工厂生产流转管理系统 API",
|
||
version=settings.APP_VERSION,
|
||
lifespan=lifespan,
|
||
)
|
||
|
||
# ---- CORS 跨域配置(从环境变量读取白名单) ----
|
||
app.add_middleware(
|
||
CORSMiddleware,
|
||
allow_origins=settings.CORS_ORIGINS_LIST,
|
||
allow_credentials=True,
|
||
allow_methods=["*"],
|
||
allow_headers=["*"],
|
||
# 暴露给浏览器 JS 读取:前端报错时才能把 request_id 一起带上便于对账
|
||
expose_headers=["X-Request-ID"],
|
||
)
|
||
|
||
# Starlette 的 add_middleware 是「后添加者在外层」。执行顺序(由外到内):
|
||
# RequestContextMiddleware -> AuditMiddleware -> CORS -> 路由
|
||
# AuditMiddleware 必须在 RequestContext 内层,才能读到后者写入 request.state
|
||
# 的 request_id,从而把审计记录与结构化日志对上。
|
||
app.add_middleware(AuditMiddleware)
|
||
app.add_middleware(RequestContextMiddleware)
|
||
|
||
|
||
@app.exception_handler(Exception)
|
||
async def unhandled_exception_handler(request: Request, exc: Exception) -> JSONResponse:
|
||
"""兜底异常处理。
|
||
|
||
完整堆栈只进日志;响应体仅返回 request_id —— 既不把内部实现泄露给客户端,
|
||
又让用户报障时能凭这个 ID 在日志里精确定位到本次失败。
|
||
"""
|
||
# 优先取 request.state(见 RequestContextMiddleware 的说明):
|
||
# 本处理器由 ServerErrorMiddleware 调用,此时 contextvar 已被重置
|
||
request_id = getattr(request.state, "request_id", None) or request_id_var.get()
|
||
logger.exception(
|
||
"未处理异常: %s %s",
|
||
request.method,
|
||
request.url.path,
|
||
extra={"extra_fields": {"method": request.method, "path": request.url.path}},
|
||
)
|
||
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,
|
||
)
|
||
|
||
|
||
# ---- 注册路由 ----
|
||
app.include_router(api_router, prefix="/api/v1")
|
||
# 健康检查挂在根路径(/health*),运维探针不经过 /api/v1
|
||
app.include_router(health_router)
|