import json from datetime import datetime, timezone as dt_tz 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 request.headers.get("Referer") or "" if int(request.headers.get("Content-Length") or 0) > 262144: 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 枚举探测 host = (origin.split("//")[-1].split("/")[0].split(":")[0].lower() if origin 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", "")) resp = Response({"code": 200, "data": {"accepted": r["accepted"], "duplicated": r["duplicated"]}, "message": "ok"}) if origin: # 成功分支回显 ACAO(SDK fetch 需读响应) resp["Access-Control-Allow-Origin"] = origin resp["Access-Control-Allow-Headers"] = "content-type" return resp def options(self, request): # 预检兜底(SDK text/plain 简单请求通常不触发) origin = request.headers.get("Origin") or "" resp = Response(status=200) if origin: resp["Access-Control-Allow-Origin"] = origin resp["Access-Control-Allow-Methods"] = "POST, OPTIONS" resp["Access-Control-Allow-Headers"] = "content-type" return resp 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): resp = Response({"code": code, "data": None, "message": reason}, status=code) origin = self.request.headers.get("Origin") if origin: # 拒绝也要回 ACAO,否则浏览器读不到错误体、SDK 误判为网络错误走重试 resp["Access-Control-Allow-Origin"] = origin return resp