8a8ae4ed57
1. devices.py: /tags 和 /export/csv 移到 /{device_id}(int) 之前,
避免 FastAPI 将 'tags'/'export' 当作 device_id 解析失败返回 422
2. audit.py: /logs/export/csv 移到 /logs/{log_id}(int) 之前,同上
3. auth.py: Header(...) → Header(None),统一为手动 401 返回,
消除全局最后一个 Header(...) 导致的 422 隐患
Co-Authored-By: Claude <noreply@anthropic.com>
158 lines
5.2 KiB
Python
158 lines
5.2 KiB
Python
"""审计日志 API"""
|
|
from datetime import datetime, timedelta
|
|
from typing import Optional
|
|
from fastapi import APIRouter, Depends, Query
|
|
from fastapi.responses import StreamingResponse
|
|
from sqlalchemy.orm import Session
|
|
from app.core.database import get_db
|
|
from app.middleware.permission_middleware import require_permission
|
|
from app.models.audit_log import AuditLog
|
|
from app.services.audit_service import query_logs
|
|
import csv
|
|
import io
|
|
|
|
router = APIRouter(prefix="/api/audit", tags=["审计日志"])
|
|
|
|
|
|
@router.get("/logs")
|
|
def get_audit_logs(
|
|
start_time: Optional[datetime] = Query(None),
|
|
end_time: Optional[datetime] = Query(None),
|
|
user_id: Optional[str] = Query(None),
|
|
username: Optional[str] = Query(None),
|
|
action_type: Optional[str] = Query(None),
|
|
resource_type: Optional[str] = Query(None),
|
|
status: Optional[str] = Query(None),
|
|
page: int = Query(1, ge=1),
|
|
page_size: int = Query(50, ge=1, le=200),
|
|
db: Session = Depends(get_db),
|
|
_: dict = Depends(require_permission('*')),
|
|
):
|
|
"""查询审计日志(仅管理员)"""
|
|
total, items = query_logs(
|
|
db,
|
|
start_time=start_time,
|
|
end_time=end_time,
|
|
user_id=user_id,
|
|
username=username,
|
|
action_type=action_type,
|
|
resource_type=resource_type,
|
|
status=status,
|
|
page=page,
|
|
page_size=page_size,
|
|
)
|
|
return {
|
|
"total": total,
|
|
"page": page,
|
|
"page_size": page_size,
|
|
"items": [_fmt(r) for r in items],
|
|
}
|
|
|
|
|
|
@router.get("/logs/export/csv")
|
|
def export_audit_logs(
|
|
start_time: Optional[datetime] = Query(None),
|
|
end_time: Optional[datetime] = Query(None),
|
|
action_type: Optional[str] = Query(None),
|
|
status: Optional[str] = Query(None),
|
|
db: Session = Depends(get_db),
|
|
_: dict = Depends(require_permission('*')),
|
|
):
|
|
"""导出审计日志为 CSV"""
|
|
_, items = query_logs(
|
|
db,
|
|
start_time=start_time,
|
|
end_time=end_time,
|
|
action_type=action_type,
|
|
status=status,
|
|
page=1,
|
|
page_size=5000,
|
|
)
|
|
|
|
def generate():
|
|
buf = io.StringIO()
|
|
writer = csv.writer(buf)
|
|
writer.writerow(["时间", "用户", "角色", "操作类型", "子类型", "路径", "状态码", "状态", "IP", "描述"])
|
|
for r in items:
|
|
t_cst = (r.action_time + timedelta(hours=8)).strftime("%Y-%m-%d %H:%M:%S") if r.action_time else ""
|
|
writer.writerow([
|
|
t_cst,
|
|
r.username, r.user_role, r.action_type, r.action_subtype or "",
|
|
f"{r.request_method} {r.request_path}", r.status_code, r.status,
|
|
r.ip_address or "", r.description,
|
|
])
|
|
yield buf.getvalue().encode("utf-8-sig")
|
|
|
|
filename = f"audit_{datetime.now().strftime('%Y%m%d%H%M%S')}.csv"
|
|
return StreamingResponse(
|
|
generate(),
|
|
media_type="text/csv",
|
|
headers={"Content-Disposition": f"attachment; filename={filename}"},
|
|
)
|
|
|
|
|
|
@router.get("/logs/{log_id}")
|
|
def get_audit_log_detail(
|
|
log_id: int,
|
|
db: Session = Depends(get_db),
|
|
_: dict = Depends(require_permission('*')),
|
|
):
|
|
"""获取单条审计日志详情"""
|
|
log = db.query(AuditLog).filter(AuditLog.id == log_id).first()
|
|
if not log:
|
|
from fastapi import HTTPException
|
|
raise HTTPException(status_code=404, detail="日志不存在")
|
|
return _fmt(log, detail=True)
|
|
|
|
|
|
@router.get("/stats")
|
|
def get_audit_stats(
|
|
days: int = Query(7, ge=1, le=90),
|
|
db: Session = Depends(get_db),
|
|
_: dict = Depends(require_permission('*')),
|
|
):
|
|
"""审计日志统计(最近N天)"""
|
|
from datetime import timedelta
|
|
from sqlalchemy import func
|
|
since = datetime.utcnow() - timedelta(days=days)
|
|
rows = (
|
|
db.query(AuditLog.action_type, AuditLog.status, func.count().label("cnt"))
|
|
.filter(AuditLog.action_time >= since)
|
|
.group_by(AuditLog.action_type, AuditLog.status)
|
|
.all()
|
|
)
|
|
total = db.query(func.count(AuditLog.id)).filter(AuditLog.action_time >= since).scalar()
|
|
by_type = {}
|
|
for row in rows:
|
|
if row.action_type not in by_type:
|
|
by_type[row.action_type] = {"success": 0, "failed": 0, "error": 0}
|
|
by_type[row.action_type][row.status] = row.cnt
|
|
return {"total": total, "days": days, "by_type": by_type}
|
|
|
|
|
|
def _fmt(r: AuditLog, detail: bool = False) -> dict:
|
|
base = {
|
|
"id": r.id,
|
|
"action_time": (r.action_time.isoformat() + "Z") if r.action_time else None,
|
|
"user_id": r.user_id,
|
|
"username": r.username,
|
|
"user_role": r.user_role,
|
|
"action_type": r.action_type,
|
|
"action_subtype": r.action_subtype,
|
|
"request_method": r.request_method,
|
|
"request_path": r.request_path,
|
|
"status": r.status,
|
|
"status_code": r.status_code,
|
|
"resource_type": r.resource_type,
|
|
"resource_id": r.resource_id,
|
|
"resource_name": r.resource_name,
|
|
"description": r.description,
|
|
"ip_address": r.ip_address,
|
|
}
|
|
if detail:
|
|
base["request_params"] = r.request_params
|
|
base["response_data"] = r.response_data
|
|
base["error_message"] = r.error_message
|
|
base["user_agent"] = r.user_agent
|
|
return base
|