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:
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)
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user