220 lines
7.6 KiB
Python
220 lines
7.6 KiB
Python
"""审计日志查询 API(迭代第 4 轮)。
|
|
|
|
迭代第 3 轮把审计写入了数据库,但**没有查询入口**——写了看不到等于没写。
|
|
本模块提供:
|
|
- `GET /api/v1/core/audit-logs/` 操作轨迹列表(按目标/动作/用户筛选)
|
|
- `GET /api/v1/core/audit-logs/summary/` 按动作类型的统计(近 N 天)
|
|
|
|
设计:只读、按租户隔离、默认最近 100 条。审计日志本身**不可修改/删除**
|
|
(没有任何写接口),保证证据链完整。
|
|
"""
|
|
|
|
import json
|
|
|
|
from adrf.decorators import api_view
|
|
from asgiref.sync import sync_to_async
|
|
from django.db.models import Count
|
|
from django.utils import timezone
|
|
from datetime import timedelta
|
|
from rest_framework.response import Response
|
|
from rest_framework.exceptions import ValidationError
|
|
|
|
from apps.core.viewset import resolve_tenant
|
|
|
|
# 业务动作 → 中文标签(前端展示用)
|
|
ACTION_LABELS = {
|
|
"post": "过账",
|
|
"cancel": "作废",
|
|
"force": "管控放行",
|
|
"price_override": "改价",
|
|
"quota_exceeded": "配额超限",
|
|
"issue": "签发",
|
|
"plan_change": "套餐变更",
|
|
"seed": "数据初始化",
|
|
}
|
|
|
|
# 风险等级:越权/放行类动作需要重点关注
|
|
ACTION_RISK = {
|
|
"force": "high",
|
|
"price_override": "medium",
|
|
"plan_change": "medium",
|
|
"quota_exceeded": "low",
|
|
"post": "info",
|
|
"cancel": "info",
|
|
"issue": "info",
|
|
"seed": "info",
|
|
}
|
|
|
|
|
|
async def _tenant_or_error(request):
|
|
code = request.META.get("HTTP_X_TENANT_ID", "")
|
|
tenant = await sync_to_async(resolve_tenant)(code)
|
|
if tenant is None:
|
|
raise ValidationError({"tenant": "无法识别租户"})
|
|
return tenant
|
|
|
|
|
|
@api_view(["GET"])
|
|
async def audit_log_list(request):
|
|
"""操作轨迹列表。
|
|
|
|
参数:target_type / target_id / action / user_id / days / limit
|
|
"""
|
|
tenant = await _tenant_or_error(request)
|
|
from apps.core.audit import query_logs
|
|
|
|
target_type = request.query_params.get("target_type", "").strip()
|
|
target_id = request.query_params.get("target_id", "").strip()
|
|
action = request.query_params.get("action", "").strip()
|
|
days = request.query_params.get("days", "")
|
|
try:
|
|
limit = min(int(request.query_params.get("limit", 100)), 500)
|
|
except (TypeError, ValueError):
|
|
limit = 100
|
|
|
|
def _load():
|
|
from apps.core.models import AuditLog
|
|
|
|
qs = AuditLog.objects.filter(tenant=tenant).select_related("user")
|
|
if target_type:
|
|
qs = qs.filter(target_type=target_type)
|
|
if target_id:
|
|
qs = qs.filter(target_id=str(target_id))
|
|
if action:
|
|
qs = qs.filter(detail__business_action=action)
|
|
if days:
|
|
try:
|
|
since = timezone.now() - timedelta(days=int(days))
|
|
qs = qs.filter(created_at__gte=since)
|
|
except (TypeError, ValueError):
|
|
pass
|
|
# 双键排序:同一秒内创建的多条记录 created_at 相同,
|
|
# 仅按时间排序不稳定(实测出现 SEQ4/SEQ2/SEQ3 的乱序),
|
|
# 加 -id 保证"最新在前"的顺序确定
|
|
return list(qs.order_by("-created_at", "-id")[:limit])
|
|
|
|
logs = await sync_to_async(_load)()
|
|
return Response({
|
|
"count": len(logs),
|
|
"results": [
|
|
{
|
|
"id": log.id,
|
|
"created_at": log.created_at.isoformat(),
|
|
"action": log.detail.get("business_action", log.action),
|
|
"action_label": ACTION_LABELS.get(
|
|
log.detail.get("business_action", ""), log.get_action_display()
|
|
),
|
|
"risk": ACTION_RISK.get(log.detail.get("business_action", ""), "info"),
|
|
"target_type": log.target_type,
|
|
"target_id": log.target_id,
|
|
"user": (
|
|
{"id": log.user_id, "username": log.user.username}
|
|
if log.user_id else None
|
|
),
|
|
"ip": str(log.ip) if log.ip else None,
|
|
"user_agent": log.user_agent,
|
|
"detail": log.detail,
|
|
}
|
|
for log in logs
|
|
],
|
|
})
|
|
|
|
|
|
@api_view(["GET"])
|
|
async def audit_log_summary(request):
|
|
"""按动作类型统计(近 N 天,默认 30)。
|
|
|
|
用于首页/风控页展示"最近有多少次管控放行"这类指标。
|
|
"""
|
|
tenant = await _tenant_or_error(request)
|
|
try:
|
|
days = int(request.query_params.get("days", 30))
|
|
except (TypeError, ValueError):
|
|
days = 30
|
|
|
|
def _load():
|
|
from apps.core.models import AuditLog
|
|
|
|
since = timezone.now() - timedelta(days=days)
|
|
qs = AuditLog.objects.filter(tenant=tenant, created_at__gte=since)
|
|
rows = (
|
|
qs.values("detail__business_action")
|
|
.annotate(n=Count("id"))
|
|
.order_by("-n")
|
|
)
|
|
total = qs.count()
|
|
# 高风险动作计数(放行类)
|
|
high_risk = sum(
|
|
r["n"] for r in rows
|
|
if ACTION_RISK.get(r["detail__business_action"] or "", "info") == "high"
|
|
)
|
|
return rows, total, high_risk
|
|
|
|
rows, total, high_risk = await sync_to_async(_load)()
|
|
return Response({
|
|
"days": days,
|
|
"total": total,
|
|
"high_risk_count": high_risk,
|
|
"items": [
|
|
{
|
|
"action": r["detail__business_action"] or "unknown",
|
|
"label": ACTION_LABELS.get(
|
|
r["detail__business_action"] or "", r["detail__business_action"] or "未知"
|
|
),
|
|
"risk": ACTION_RISK.get(r["detail__business_action"] or "", "info"),
|
|
"count": r["n"],
|
|
}
|
|
for r in rows
|
|
],
|
|
})
|
|
|
|
|
|
@api_view(["GET"])
|
|
async def audit_log_for_target(request):
|
|
"""某张单据的完整轨迹("这单都发生了什么")。
|
|
|
|
参数:target_type(必填)、target_id(必填)
|
|
"""
|
|
tenant = await _tenant_or_error(request)
|
|
target_type = request.query_params.get("target_type", "").strip()
|
|
target_id = request.query_params.get("target_id", "").strip()
|
|
if not (target_type and target_id):
|
|
raise ValidationError({"detail": "target_type 与 target_id 必填"})
|
|
|
|
from apps.core.audit import query_logs
|
|
|
|
def _load():
|
|
from django.db.models import Q
|
|
|
|
from apps.core.models import AuditLog
|
|
|
|
# 轨迹要覆盖"与这张单相关的所有操作",而不仅是 target_type 完全匹配的:
|
|
# 例如强制放行记录挂在管控类型下(target_type=credit_limit),
|
|
# 但 detail.bill_no 才是它真正作用的单据。只按 target_type 过滤会把这些
|
|
# "同一单据的其他动作"漏掉,用户看到的轨迹是割裂的。
|
|
qs = AuditLog.objects.filter(tenant=tenant).filter(
|
|
Q(target_type=target_type, target_id=str(target_id))
|
|
| Q(detail__bill_no=str(target_id))
|
|
).select_related("user")
|
|
# 按 id 正序:同一秒内的操作也能还原真实先后顺序
|
|
return list(qs.order_by("id")[:200])
|
|
|
|
logs = await sync_to_async(_load)()
|
|
return Response({
|
|
"target": {"type": target_type, "id": target_id},
|
|
"count": len(logs),
|
|
"timeline": [
|
|
{
|
|
"at": log.created_at.isoformat(),
|
|
"action": log.detail.get("business_action"),
|
|
"action_label": ACTION_LABELS.get(
|
|
log.detail.get("business_action", ""), "操作"
|
|
),
|
|
"user": log.user.username if log.user_id else "系统",
|
|
"ip": str(log.ip) if log.ip else None,
|
|
"detail": log.detail,
|
|
}
|
|
for log in logs # 已按 id 正序(真实操作顺序)
|
|
],
|
|
})
|