Files
fangke/server/tracker/api/collect.py
T
浅念 cf04db794b feat: 采集接口(域名白名单/限流/textplain解析/幂等)
POST /api/collect/batch:text/plain JSON 解析(ParseError 400 防 500)、
域名白名单/站点停用/未知站点统一 403 not_allowed 防枚举、
Anon 限流(collect scope,get_rate 读实时 api_settings 修 import 固化)、
occurred_at ISO 归一(naive 补 UTC)、XFF 末段取 IP + ::ffff: 归一、
成功/拒绝分支均回显 ACAO 供 SDK 读响应。

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

102 lines
4.8 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 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