baseline: 批次A-D 成果 + membership 半成品(测试红)
This commit is contained in:
@@ -0,0 +1,219 @@
|
||||
"""审计日志查询 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 正序(真实操作顺序)
|
||||
],
|
||||
})
|
||||
Reference in New Issue
Block a user