Files
track/backend/app/api/v1/endpoints/audit.py
duxingchen 1fea30b03b feat(audit): 日活使用统计 + 双 CSV 导出(审计/上下线,列可自定义)
【日活统计:GET /audit/daily-usage】
按【北京时间自然日 × 操作人】聚合,单个 GROUP BY 完成(count(*) FILTER),
不用窗口函数。指标:上线/下线时间、操作次数、登录/登出次数。

⚠️ 上线/下线时间取【当天首次/末次活动】,刻意不取登录时间:
   Refresh Token 有效期 7 天,用户不必每天重新登录。按登录算会出现
   「登录次数 0、上线时间空,但操作次数 35」——报表自相矛盾。
   时间一律按 +08:00 分日与渲染,否则早班(00:00~08:00)操作会掉到前一天。

【CSV 导出:两个端点 + 列自定义】
- /audit/logs/export        整体审计导出,筛选维度与列表页完全一致
- /audit/daily-usage/export 上下线导出,每人一行
- 列清单由后端统一维护并经 /audit/options 下发(log_export_columns /
  usage_export_columns),前端不硬编码表头,避免两端漂移
- ⚠️ 响应带 UTF-8 BOM:Excel 靠它识别编码,否则中文表头全乱码
- 单次上限 5 万行,超出经 X-Export-Truncated 头告知前端明确提示
  (静默截断比报错更危险)
- list_audit_logs 与 export_audit_logs 共用 _log_filters,
  保证「看到的」与「导出的」永远是同一批数据

【前端】
- 审计页页头新增「人员统计」「导出 CSV」两个按钮,现有表格与筛选零改动
- 人员统计走抽屉(AuditUsagePanel):日期范围+快捷键、日活表格、
  上下线次数彩色标签、北京时间渲染、导出前弹列勾选面板
- ExportColumnsModal 为两处导出共用,默认全选
2026-09-21 12:06:43 +08:00

293 lines
13 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""审计日志 API —— 查看系统操作审计记录
与 MOM(KCGL) /audit/logs 的接口保持同构的筛选维度(操作人/模块/动作/目标/
时间区间),便于两端运维习惯统一;额外提供 request_id 筛选,可凭它直接跳到
结构化日志里的那一次请求。
另提供两个 CSV 导出端点(审计明细 / 日活统计),均支持按列导出。
"""
from __future__ import annotations
import csv
import io
from datetime import datetime, time, timedelta
from typing import Any, Callable
from fastapi import APIRouter, Depends, Query, Response
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, get_beijing_time
from app.schemas.audit import (
AuditLogListResponse,
AuditLogResponse,
AuditOption,
AuditOptionsResponse,
DailyUsageResponse,
DailyUsageRow,
)
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()],
log_export_columns=[
AuditOption(value=k, label=v[0]) for k, v in _AUDIT_LOG_COLUMNS.items()
],
usage_export_columns=[
AuditOption(value=k, label=v[0]) for k, v in _DAILY_USAGE_COLUMNS.items()
],
)
# ============================================================
# CSV 导出
# ============================================================
def _bj(dt: datetime | None) -> str:
"""时间列统一按北京时间输出(与列表页、日活分日口径一致)。
直接输出 UTC 会让导出文件里 01:00 的操作显示成前一天 17:00
与网页上看到的对不上 —— 导出与页面不一致是最容易被质疑的那种问题。
"""
if dt is None:
return ""
return dt.astimezone(BEIJING_TZ).strftime("%Y-%m-%d %H:%M:%S")
def _actor(log) -> str:
"""操作人:优先中文名,退化为账号(与列表页的展示规则一致)"""
if not log.user_id and not log.display_name:
return "未认证"
return f"{log.display_name}({log.user_id})" if log.display_name else (log.user_id or "")
# 列定义key → (表头, 取值函数)。
# 前端只传 key 列表,中文表头与取值口径都由后端统一维护,
# 避免两端各写一份导致"导出的列和页面上的对不上"。
_AUDIT_LOG_COLUMNS: dict[str, tuple[str, Callable[[Any], Any]]] = {
"time": ("时间", lambda r: _bj(r.created_at)),
"user": ("操作人", _actor),
"role": ("角色", lambda r: r.role or ""),
"module": ("模块", lambda r: MODULE_LABELS.get(r.module, r.module)),
"action": ("动作", lambda r: ACTION_LABELS.get(r.action, r.action)),
"method": ("方法", lambda r: r.method or ""),
"url": ("请求路径", lambda r: r.url or ""),
"status": ("结果", lambda r: r.status_code if r.status_code is not None else ""),
"ip": ("来源IP", lambda r: r.ip_address or ""),
"target": ("目标", lambda r: f"{r.target_type or ''}:{r.target_id or ''}".strip(":")),
"error": ("错误信息", lambda r: r.error_message or ""),
"request_id": ("请求ID", lambda r: r.request_id or ""),
"user_agent": ("User-Agent", lambda r: r.user_agent or ""),
}
_DAILY_USAGE_COLUMNS: dict[str, tuple[str, Callable[[dict], Any]]] = {
"day": ("日期", lambda r: r["day"]),
"user": ("操作人", lambda r: f"{r['display_name']}({r['user_id']})" if r["display_name"] else (r["user_id"] or "")),
"role": ("角色", lambda r: r["role"] or ""),
# 上线/下线时间 = 当天首次/末次活动(非登录时间),
# 登录/登出次数单独成列,两者不再混为一谈
"first_active": ("上线时间", lambda r: _bj(r["first_active_at"])),
"last_active": ("下线时间", lambda r: _bj(r["last_active_at"])),
"login_count": ("登录次数", lambda r: r["login_count"]),
"logout_count": ("登出次数", lambda r: r["logout_count"]),
"op_count": ("操作次数", lambda r: r["op_count"]),
}
def _csv_response(
columns: dict[str, tuple[str, Callable]], keys: list[str], rows: list, filename: str,
) -> Response:
"""把行数据渲染成 CSV 响应。
⚠️ 必须带 UTF-8 BOMExcel 靠它识别编码,否则中文表头与内容全是乱码。
这是 CSV 导出最常见、也最容易被忽略的坑。
"""
buf = io.StringIO()
writer = csv.writer(buf)
writer.writerow([columns[k][0] for k in keys])
for row in rows:
writer.writerow([columns[k][1](row) for k in keys])
return Response(
content=b"\xef\xbb\xbf" + buf.getvalue().encode("utf-8"),
media_type="text/csv; charset=utf-8",
# 文件名用纯 ASCII中文文件名要走 RFC 5987各浏览器行为不一致
# 内部系统没必要为它引入兼容成本。
headers={"Content-Disposition": f'attachment; filename="{filename}"'},
)
def _resolve_keys(raw: str | None, columns: dict) -> list[str]:
"""解析前端传来的列 key。缺省 = 全部列;未知 key 直接忽略(不报错)。"""
if not raw:
return list(columns)
keys = [k.strip() for k in raw.split(",") if k.strip() in columns]
return keys or list(columns)
@router.get("/logs/export")
async def export_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含当天"),
columns: str | None = Query(None, description="导出列,逗号分隔;缺省=全部"),
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(require_admin),
) -> Response:
"""审计明细 CSV 导出 —— 筛选维度与 /logs 完全一致,保证"看到什么就能导出什么""""
start = _parse_day(start_date)
end_exclusive = _parse_day(end_date, end_of_day=True)
rows, truncated = await audit_service.export_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,
)
keys = _resolve_keys(columns, _AUDIT_LOG_COLUMNS)
resp = _csv_response(_AUDIT_LOG_COLUMNS, keys, rows, "audit_logs.csv")
if truncated:
# 用响应头传递"已截断",前端据此提示用户收窄筛选条件
resp.headers["X-Export-Truncated"] = "1"
resp.headers["X-Export-Max-Rows"] = str(audit_service.EXPORT_MAX_ROWS)
resp.headers["Access-Control-Expose-Headers"] = "X-Export-Truncated, X-Export-Max-Rows"
return resp
@router.get("/daily-usage/export")
async def export_daily_usage(
start_date: str | None = Query(None, description="起始日期 YYYY-MM-DD北京时间默认今天"),
end_date: str | None = Query(None, description="结束日期 YYYY-MM-DD北京时间默认同起始日"),
columns: str | None = Query(None, description="导出列,逗号分隔;缺省=全部"),
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(require_admin),
) -> Response:
"""日活统计 CSV 导出 —— 每人一行:上线/下线次数与时间、操作次数。"""
start = _parse_day(start_date) or datetime.combine(
get_beijing_time().date(), time.min, tzinfo=BEIJING_TZ,
)
end = _parse_day(end_date, end_of_day=True) or (start + timedelta(days=1))
items = await audit_service.get_daily_usage(db, start=start, end=end)
keys = _resolve_keys(columns, _DAILY_USAGE_COLUMNS)
return _csv_response(_DAILY_USAGE_COLUMNS, keys, items, "daily_usage.csv")
@router.get("/daily-usage", response_model=DailyUsageResponse)
async def get_daily_usage(
start_date: str | None = Query(None, description="起始日期 YYYY-MM-DD北京时间默认今天"),
end_date: str | None = Query(None, description="结束日期 YYYY-MM-DD北京时间默认同起始日"),
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(require_admin),
) -> DailyUsageResponse:
"""日活 / 使用统计 —— 按【北京时间自然日 × 操作人】聚合。
回答的是「每天有哪些人用了系统、用了多少」:
· 上线时间 / 下线时间:当天**首次 / 末次活动**时间(任意审计记录)
· 操作次数:当天该用户的全部审计记录数(使用深度)
· 登录次数 / 登出次数:真实的手动登录 / 登出行为计数
⚠️ 上线时间【不取登录时间】token 有效期内refresh 7 天)用户不重新登录,
按登录算会让「周一登录、周二继续用」的周二变成"登录次数 0、上线时间空
但操作次数 35"——报表自相矛盾。改用活动口径后,当天的第一次操作即上线时间。
⚠️ 登出次数天然小于登录次数用户直接关浏览器、断网、token 过期都不会
产生登出记录。这是真实情况,不做任何"补齐"推算。
"""
# 起始日未传则取北京的今天。_parse_day 返回的是北京时间当日 00:00。
start = _parse_day(start_date) or datetime.combine(
get_beijing_time().date(), time.min, tzinfo=BEIJING_TZ,
)
# 结束日_parse_day(end_of_day=True) 已给出「次日 00:00」正好当作半开上界。
# 未传则默认单日查询(= 起始日当天)。
end = _parse_day(end_date, end_of_day=True) or (start + timedelta(days=1))
items = await audit_service.get_daily_usage(db, start=start, end=end)
return DailyUsageResponse(
start_date=start.astimezone(BEIJING_TZ).strftime("%Y-%m-%d"),
end_date=(end - timedelta(days=1)).astimezone(BEIJING_TZ).strftime("%Y-%m-%d"),
items=[DailyUsageRow(**row) for row in items],
total=len(items),
)