"""审计日志 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()], )