diff --git a/backend/app/api/v1/endpoints/audit.py b/backend/app/api/v1/endpoints/audit.py
index 939cda2..55de865 100644
--- a/backend/app/api/v1/endpoints/audit.py
+++ b/backend/app/api/v1/endpoints/audit.py
@@ -3,22 +3,29 @@
与 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
+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
+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
@@ -92,8 +99,194 @@ async def get_audit_logs(
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 BOM:Excel 靠它识别编码,否则中文表头与内容全是乱码。
+ 这是 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),
)
diff --git a/backend/app/schemas/audit.py b/backend/app/schemas/audit.py
index 0e26eaa..b63877a 100644
--- a/backend/app/schemas/audit.py
+++ b/backend/app/schemas/audit.py
@@ -45,6 +45,32 @@ class AuditLogListResponse(BaseModel):
total: int
+class DailyUsageRow(BaseModel):
+ """某个操作人在某一天的用量汇总(北京时间自然日)"""
+ day: str # YYYY-MM-DD(北京时间)
+ user_id: str | None = None
+ display_name: str | None = None
+ role: str | None = None
+
+ login_count: int = 0 # 登录次数(当天成功登录)
+ logout_count: int = 0 # 登出次数(当天成功登出)
+ op_count: int = 0 # 操作次数(当天全部审计记录数)
+
+ # ⚠️ 上线/下线时间取【当天首次/末次活动】,不是登录/登出时间:
+ # token 有效期内(refresh 7 天)用户不会重新登录,按登录算会导致
+ # 「登录次数 0 但操作 35 次」这种自相矛盾。
+ first_active_at: datetime | None = None # 上线时间(当天首次活动)
+ last_active_at: datetime | None = None # 下线时间(当天末次活动)
+
+
+class DailyUsageResponse(BaseModel):
+ """日活 / 使用统计"""
+ start_date: str
+ end_date: str
+ items: list[DailyUsageRow]
+ total: int # 行数(= 天数 × 人数),不是审计记录数
+
+
class AuditOption(BaseModel):
"""筛选项(value/label 结构,直接喂给前端下拉)"""
value: str
@@ -55,3 +81,8 @@ class AuditOptionsResponse(BaseModel):
"""筛选项集合"""
modules: list[AuditOption]
actions: list[AuditOption]
+ # 导出可选的列(value=后端列 key,label=中文表头)。
+ # 由后端下发而非前端硬编码:列的中文名与取值口径都在后端,
+ # 两端各写一份迟早会出现"导出的列和页面上的对不上"。
+ log_export_columns: list[AuditOption] = []
+ usage_export_columns: list[AuditOption] = []
diff --git a/backend/app/services/audit_service.py b/backend/app/services/audit_service.py
index 532e17c..e032902 100644
--- a/backend/app/services/audit_service.py
+++ b/backend/app/services/audit_service.py
@@ -18,7 +18,7 @@ import logging
import uuid
from datetime import datetime
-from sqlalchemy import func, select
+from sqlalchemy import and_, func, select
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.database import AsyncSessionLocal
@@ -165,6 +165,54 @@ async def list_audit_logs(
真实总数走独立 COUNT —— 前端分页器依赖它,不能用 len(当前页)。
"""
+ filters = _log_filters(
+ user_id=user_id, module=module, action=action, target_id=target_id,
+ request_id=request_id, status_code=status_code, start=start, end=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
+
+
+# ============================================================
+# 导出
+# ============================================================
+
+# 单次导出的行数上限。审计表只增不减,全量导出迟早会撑爆内存与浏览器,
+# 故设硬上限;超出时向上层返回 truncated=True,由前端明确提示「已截断」——
+# 静默截断会让使用者以为导全了,比报错更危险。
+EXPORT_MAX_ROWS = 50000
+
+
+def _log_filters(
+ *,
+ 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,
+) -> list:
+ """审计日志的筛选条件 —— list_audit_logs 与 export_audit_logs 共用。
+
+ 抽出来的唯一目的:保证「列表看到的」和「导出出去的」永远是同一批数据。
+ 两处各写一份迟早会漂移,而导出与列表不一致是最让人不信任的那种 bug。
+ """
filters = []
if user_id:
filters.append(AuditLog.user_id.ilike(f"%{user_id}%"))
@@ -182,19 +230,110 @@ async def list_audit_logs(
filters.append(AuditLog.created_at >= start)
if end:
filters.append(AuditLog.created_at <= end)
+ return filters
- total = await db.scalar(
- select(func.count()).select_from(AuditLog).where(*filters)
- ) or 0
+async def export_audit_logs(
+ db: AsyncSession, *, limit: int = EXPORT_MAX_ROWS, **kwargs,
+) -> tuple[list[AuditLog], bool]:
+ """导出用:按筛选条件取全部记录(不分页)。返回 (rows, truncated)。
+
+ 多取一行来判断是否被截断 —— 比再跑一次 COUNT 便宜。
+ """
rows = (
await db.execute(
select(AuditLog)
- .where(*filters)
+ .where(*_log_filters(**kwargs))
.order_by(AuditLog.created_at.desc())
- .offset(skip)
- .limit(limit)
+ .limit(limit + 1)
)
).scalars().all()
+ truncated = len(rows) > limit
+ return list(rows[:limit]), truncated
- return list(rows), total
+
+# ============================================================
+# 日活 / 使用统计
+# ============================================================
+
+# 成功 = 2xx/3xx。登录失败(401)也要留痕,但不应计入"上线次数"。
+_OK_STATUS_UPPER = 400
+
+
+async def get_daily_usage(
+ db: AsyncSession, *, start: datetime, end: datetime,
+) -> list[dict]:
+ """按【北京时间自然日 × 操作人】聚合用量 —— 日活报表的数据源。
+
+ start/end 为半开区间 [start, end),调用方按北京时间日界传入。
+
+ 全部指标由**一个 GROUP BY 查询**算出,不用窗口函数:
+ · 登录/登出次数 = 成功登录 / 成功登出数(最终凭证是 login_count,不是"上线次数")
+ · 操作频次 = 当天该用户的全部审计记录数(代表系统使用深度)
+ · 上线/下线时间 = 当天**首次 / 末次活动**时间(任意审计记录)
+
+ ⚠️ 上线/下线时间【不能】取登录/登出时间。
+ Access/Refresh Token 有效期内(refresh 7 天)用户无需重新登录,
+ 于是"周一登录、周二到周日继续用"会导致周二~周日:
+ 登录次数=0、登录时间=空,但操作次数却是几十 —— 报表自相矛盾。
+ 改用活动口径后,工人当天的第一次操作就是真实上线时间。
+ (审计中间件是全站的,移动端的接收/转交/完工同样入账,故自动覆盖移动端,
+ 无需前端上报心跳。)
+
+ 为什么用 `count(*) FILTER (WHERE ...)`:分组内一次扫描同时算出多个条件计数,
+ 比多次子查询或 UNION 简单得多,且语义一眼可读。Postgres 原生支持。
+
+ ⚠️ 按【北京时间】分日:created_at 是 timestamptz(实存 UTC),
+ 直接按 UTC 分日会让 00:00~08:00 的早班操作掉到前一天。
+ """
+ day_col = func.date(func.timezone("Asia/Shanghai", AuditLog.created_at))
+
+ login_ok = and_(
+ AuditLog.action == "login", AuditLog.status_code < _OK_STATUS_UPPER,
+ )
+ logout_ok = and_(
+ AuditLog.action == "logout", AuditLog.status_code < _OK_STATUS_UPPER,
+ )
+
+ stmt = (
+ select(
+ day_col.label("day"),
+ AuditLog.user_id.label("user_id"),
+ # 同一用户的 display_name / role 是一致的,取 max 只是为了
+ # 在 GROUP BY 下拿到一个非空代表值(避免再套一层 DISTINCT ON)
+ func.max(AuditLog.display_name).label("display_name"),
+ func.max(AuditLog.role).label("role"),
+ func.count().filter(login_ok).label("login_count"),
+ func.count().filter(logout_ok).label("logout_count"),
+ func.count().label("op_count"),
+ # 上线/下线时间取「任意记录」的首末,而不是登录/登出的首末(原因见 docstring)
+ func.min(AuditLog.created_at).label("first_active_at"),
+ func.max(AuditLog.created_at).label("last_active_at"),
+ )
+ .where(
+ AuditLog.created_at >= start,
+ AuditLog.created_at < end,
+ # 只统计"人":未认证请求(如登录前的探测、refresh)没有操作人,
+ # 混进来会让"日活人数"虚高。若要排查匿名异常流量,走日志列表页按
+ # 结果/来源 IP 过滤更合适。
+ AuditLog.user_id.isnot(None),
+ )
+ .group_by(day_col, AuditLog.user_id)
+ .order_by(day_col.desc(), func.count().desc())
+ )
+
+ rows = (await db.execute(stmt)).all()
+ return [
+ {
+ "day": r.day.strftime("%Y-%m-%d") if hasattr(r.day, "strftime") else str(r.day),
+ "user_id": r.user_id,
+ "display_name": r.display_name,
+ "role": r.role,
+ "login_count": r.login_count or 0,
+ "logout_count": r.logout_count or 0,
+ "op_count": r.op_count or 0,
+ "first_active_at": r.first_active_at,
+ "last_active_at": r.last_active_at,
+ }
+ for r in rows
+ ]
diff --git a/frontend/src/components/admin/ExportColumnsModal.tsx b/frontend/src/components/admin/ExportColumnsModal.tsx
new file mode 100644
index 0000000..e228b67
--- /dev/null
+++ b/frontend/src/components/admin/ExportColumnsModal.tsx
@@ -0,0 +1,75 @@
+/**
+ * 导出列选择弹窗 —— 勾选要写进 CSV 的列。
+ *
+ * 列清单由后端 /audit/options 下发(value=后端列 key,label=中文表头),
+ * 前端不硬编码表头:否则两端各维护一份,迟早出现「导出的列和页面对不上」。
+ *
+ * 默认全选 —— 大多数人只是想"全部导出来",不该逼他们先勾一遍。
+ */
+import { useEffect, useState } from "react";
+import { Modal, Checkbox, Button } from "antd";
+import type { AuditOption } from "../../services/auditApi";
+
+export default function ExportColumnsModal({
+ open,
+ columns,
+ submitting,
+ onCancel,
+ onConfirm,
+}: {
+ open: boolean;
+ columns: AuditOption[];
+ submitting?: boolean;
+ onCancel: () => void;
+ /** 传出当前勾选的列 key(顺序 = 后端下发顺序,保证表头稳定) */
+ onConfirm: (keys: string[]) => void;
+}) {
+ const [checked, setChecked] = useState 至少勾选一列才能导出。
+ ⓘ 「上线/下线时间」= 当天首次/末次活动时间,不是登录时间 —— + 登录状态可保持 7 天,当天不登录也会正常统计。 + 「登录/登出次数」是真实的手动登录行为计数,登出通常少于登录(关浏览器、断网不产生登出记录)。 +
+ +