Files
fangke/server/tracker/api/collect.py
T
浅念 f22786d746 refactor: 提取共享信封模块 EnvelopeMixin/ok
auth.py 的 _EnvelopeMixin/_ok 提取到 tracker/api/envelope.py(去下划线前缀),
auth.py 改为 import 使用;collect.py 的 finalize_response 含 ACAO 回显逻辑
保留在自身视图,仅注释指向未来收敛。

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-15 14:25:14 +08:00

113 lines
5.9 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import json
from datetime import datetime, timezone as dt_tz
from urllib.parse import urlsplit
from rest_framework.views import APIView
from rest_framework.response import Response
from rest_framework.permissions import AllowAny
from rest_framework.throttling import AnonRateThrottle
from rest_framework.parsers import BaseParser
from rest_framework.settings import api_settings
from tracker.models import Site
from tracker.services.ingest import ingest_batch
class PlainJSONParser(BaseParser):
media_type = "text/plain"
def parse(self, stream, *args, **kwargs):
try:
return json.loads(stream.read().decode("utf-8") or "{}")
except (ValueError, UnicodeDecodeError):
from rest_framework.exceptions import ParseError
raise ParseError("invalid_json") # DRF 对 parser 异常是 re-raise 不是包装,不自包就是 500
class CollectThrottle(AnonRateThrottle):
scope = "collect"
def get_rate(self):
# SimpleRateThrottle.THROTTLE_RATES 在 import 时固化为类属性,
# override_settings 改 DEFAULT_THROTTLE_RATES 对它不生效——改读实时 api_settings(DRF 收 setting_changed 会 reload)
return api_settings.DEFAULT_THROTTLE_RATES[self.scope]
def _iso(dt):
if isinstance(dt, str):
s = dt.replace("Z", "+00:00")
d = datetime.fromisoformat(s)
return d if d.tzinfo else d.replace(tzinfo=dt_tz.utc) # Django≥5 已删除 django.utils.timezone.utc
return dt
class CollectBatchView(APIView):
permission_classes = [AllowAny]
parser_classes = [PlainJSONParser]
throttle_classes = [CollectThrottle]
authentication_classes = []
def post(self, request):
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)
site = self._match_site(request)
if site is None or not site.is_active:
return self._deny(403, "not_allowed") # 三种拒绝统一 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")
if not host and not site.allow_no_origin:
return self._deny(403, "not_allowed")
payload = request.data if isinstance(request.data, dict) else {}
for ev in payload.get("events") or []:
if isinstance(ev, dict) and "occurred_at" in ev:
try:
ev["occurred_at"] = _iso(ev["occurred_at"])
except (ValueError, TypeError):
return self._deny(400, "invalid_occurred_at")
if not payload.get("visitor_id") or not payload.get("session_id"):
return self._deny(400, "invalid_payload")
ip = self._client_ip(request)
r = ingest_batch(site, payload, ip, request.headers.get("User-Agent", ""))
return Response({"code": 200,
"data": {"accepted": r["accepted"], "duplicated": r["duplicated"]},
"message": "ok"})
def options(self, request): # 预检兜底(SDK text/plain 简单请求通常不触发);ACAO 由 finalize_response 统一补
resp = Response(status=200)
resp["Access-Control-Allow-Methods"] = "POST, OPTIONS"
resp["Access-Control-Allow-Headers"] = "content-type"
return resp
def finalize_response(self, request, response, *args, **kwargs):
"""统一收口:ACAO 回显 + 错误信封。
错误信封部分与 tracker.api.envelope.EnvelopeMixin 同构——collect 端含 ACAO 回显
(浏览器 SDK 跨域需读错误体),暂保留在自身视图,未来收敛至共享模块。
429/ParseError 等由 DRF 异常处理器产出的响应(无 ACAO、body 是 {detail})在这里补齐——
否则浏览器 SDK 跨域读不到错误体会误判网络错误而重试,限流时反而打爆限流器。
dispatch 对 initial() 抛出的 Throttled/ParseError 同样走 finalize_response,此钩子全覆盖。
"""
response = super().finalize_response(request, response, *args, **kwargs)
origin = request.headers.get("Origin")
if origin and "Access-Control-Allow-Origin" not in response: # 只回显真正的 Origin 头
response["Access-Control-Allow-Origin"] = origin
if response.status_code >= 400 and isinstance(response.data, dict) and "detail" in response.data:
response.data = {"code": response.status_code, "data": None,
"message": response.data["detail"]}
return response
def _match_site(self, request):
sid = (request.data or {}).get("site_id") if isinstance(request.data, dict) else None
return Site.objects.filter(site_id=sid).first() if sid else None
def _client_ip(self, request):
"""nginx 前置时取 X-Forwarded-For 最后一段(与 DRF NUM_PROXIES=1 一致;套 CDN 时按层数前移)。"""
xff = request.META.get("HTTP_X_FORWARDED_FOR", "")
ip = xff.split(",")[-1].strip() if xff else request.META.get("REMOTE_ADDR")
# 归一 IPv4 映射地址(::ffff:1.2.3.4 → 1.2.3.4),防地理解析丢失
if ip and ip.lower().startswith("::ffff:"):
ip = ip[7:]
return ip
def _deny(self, code, reason):
return Response({"code": code, "data": None, "message": reason}, status=code) # ACAO/信封由 finalize_response 统一补