feat(server): ingest v2事件级归属+session_resume+stay区间并集重算+响应明细契约

Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
浅念
2026-09-18 11:14:06 +08:00
parent d8999035c9
commit 8459e6fa13
5 changed files with 321 additions and 31 deletions
+21 -12
View File
@@ -1,6 +1,8 @@
import json
from datetime import datetime, timezone as dt_tz
from urllib.parse import urlsplit
from uuid import uuid4
from django.utils import timezone as dj_timezone
from rest_framework.views import APIView
from rest_framework.response import Response
from rest_framework.permissions import AllowAny
@@ -42,20 +44,21 @@ class CollectBatchView(APIView):
authentication_classes = []
def post(self, request):
request_id = uuid4().hex[:32] # 每次接收一个:响应回传 + RawBatch 同值落库(对账/去重排查)
origin = request.headers.get("Origin") or ""
cl = request.headers.get("Content-Length") or "0"
if not cl.isdecimal() or int(cl) > 262144: # isdecimal:isdigit 放过 "²"(int 仍崩),isdecimal 与 int() 精确对齐
return self._deny(400, "payload_too_large") # 应用层兜底(nginx 另设 client_max_body_size)
return self._deny(400, "payload_too_large", request_id) # 应用层兜底(nginx 另设 client_max_body_size)
site = self._match_site(request)
if site is None or not site.is_active:
return self._deny(403, "not_allowed") # 三种拒绝统一 reason,防 site_id 枚举探测
return self._deny(403, "not_allowed", request_id) # 三种拒绝统一 reason,防 site_id 枚举探测
# urlsplit 提取 host:split("//")[-1] 会被查询串里的白名单 URL 绕过;
# "null" Origin(隐私上下文/沙箱 iframe)视为无 host,走 allow_no_origin 分支
host = (urlsplit(origin).hostname or "").lower() if origin and origin != "null" else ""
if host and host not in [d.lower() for d in site.domains]:
return self._deny(403, "not_allowed")
return self._deny(403, "not_allowed", request_id)
if not host and not site.allow_no_origin:
return self._deny(403, "not_allowed")
return self._deny(403, "not_allowed", request_id)
payload = request.data if isinstance(request.data, dict) else {}
# 原始批次快照:须在 occurred_at 字符串转 datetime 之前深拷贝(datetime 不可 JSON 序列化),
# 且独立于 ingest/validate 的就地清洗(path 截断等)——存的是"到达时"的原文(脱敏后)。
@@ -65,17 +68,20 @@ class CollectBatchView(APIView):
try:
ev["occurred_at"] = _iso(ev["occurred_at"])
except (ValueError, TypeError):
return self._deny(400, "invalid_occurred_at")
return self._deny(400, "invalid_occurred_at", request_id)
if not payload.get("visitor_id") or not payload.get("session_id"):
return self._deny(400, "invalid_payload")
return self._deny(400, "invalid_payload", request_id)
ip = self._client_ip(request)
r = ingest_batch(site, payload, ip, request.headers.get("User-Agent", ""))
try:
self._save_raw_batch(site, payload, snapshot, r, ip)
self._save_raw_batch(site, payload, snapshot, r, ip, request_id)
except Exception: # best-effort:原始表任何失败(含孤代理 encode 崩溃)不影响主采集响应
pass
# 响应契约(spec §2.3):rejected 明细回传,HTTP 200 不再隐含全量成功
return Response({"code": 200,
"data": {"accepted": r["accepted"], "duplicated": r["duplicated"]},
"data": {"accepted": r["accepted"], "duplicated": r["duplicated"],
"rejected": r["rejected"], "request_id": request_id,
"server_time": dj_timezone.now().isoformat()},
"message": "ok"})
def options(self, request): # 预检兜底(SDK text/plain 简单请求通常不触发);ACAO 由 finalize_response 统一补
@@ -103,12 +109,13 @@ class CollectBatchView(APIView):
"message": response.data["detail"]}
return response
def _save_raw_batch(self, site, payload, snapshot, r, ip):
def _save_raw_batch(self, site, payload, snapshot, r, ip, request_id=""):
"""原始批次落库(每次接收一行,含被拒批次)。失败由调用方静默吞掉。
rejected(accepted=0 且有拒绝)整批截断 4KB——超批 51 事件的原文必然超限;
partial(部分拒绝)不截:正常批次本就受 256KB 请求体上限约束,TextField 可容。
visitor 允许 None:被拒批次(如超批)不会创建访客记录。
request_id 与响应同值(对账:"这批原始数据对应哪个响应")。
"""
body = json.dumps(snapshot, ensure_ascii=False)
status = ("rejected" if not r["accepted"] and r["rejected"]
@@ -121,7 +128,7 @@ class CollectBatchView(APIView):
batch_id=str(payload.get("batch_id", ""))[:64],
body=body, event_count=r["accepted"],
rejected_count=len(r["rejected"]) if isinstance(r["rejected"], list) else r["rejected"],
status=status, ip=ip)
status=status, ip=ip, request_id=str(request_id or "")[:64])
def _match_site(self, request):
sid = (request.data or {}).get("site_id") if isinstance(request.data, dict) else None
@@ -136,5 +143,7 @@ class CollectBatchView(APIView):
ip = ip[7:]
return ip
def _deny(self, code, reason):
return Response({"code": code, "data": None, "message": reason}, status=code) # ACAO/信封由 finalize_response 统一补
def _deny(self, code, reason, request_id=""):
# data 带 request_id(对账);ACAO/信封由 finalize_response 统一补
return Response({"code": code, "data": {"request_id": request_id},
"message": reason}, status=code)