Files
fangke/server/tracker/services/ingest.py
T
2026-09-15 12:44:33 +08:00

146 lines
7.3 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.
"""批量入库服务:访客/会话 get_or_create、事件幂等写入、聚合更新。
并发不变量(四轮审查收口):
① event 绝不重复入库:uq_site_event + bulk_create(ignore_conflicts=True) + 入库前 existed 预查;
② 重放命中的事件不参与会话聚合(new_events 只含未入库事件);
③ visit_count/active_days/event_count/duration_ms/stay_ms 全走 F 表达式,
filter().update() 避免 read-modify-write 全量 save() 用旧值覆盖并发赢家;
④ 会话 ended_at 用 Greatest 只前进不回退,乱序批次不改早结束时间;
⑤ 同批切分出的会话段按"原始 sid"缓存复用,后续事件不再切分。
"""
from datetime import timedelta
from django.db import transaction, IntegrityError
from django.db.models import F
from django.db.models.functions import Greatest
from django.utils import timezone
from tracker import constants as C
from tracker.models import Visitor, VisitSession, Event
from tracker.services.validation import validate_event
from tracker.services.geo import resolver as _geo # 模块级单例(勿实例化,11MB/份)
_FIELD_LEN = {"device_type": 16, "os": 64, "browser": 64, "language": 32, "screen": 32}
def _clean_utm(d):
"""utm 键白名单(utm_* 前缀)+ 截断——任意字符串不得经 utm 入库渲染。"""
return {k[:50]: str(v)[:100] for k, v in (d or {}).items()
if isinstance(k, str) and k.startswith("utm_")}
def _geo_field(g, key):
"""geo 值归一:缺失段的 "0"/"" 占位统一为空串。geo dict 被 lru_cache 共享,只读勿改。"""
v = (g or {}).get(key, "")
return v if v not in ("", "0") else ""
def _touch_visitor(site, vid, ctx, ip):
env = {k: str(ctx.get(k, "") or "")[:n] for k, n in _FIELD_LEN.items()}
v, created = Visitor.objects.get_or_create(
site=site, visitor_id=vid, defaults={**env, "last_ip": ip})
now = timezone.now()
upd = {"last_seen_at": now} # 聚合字段一律 filter().update(),避免并发下全量 save() 用旧值覆盖赢家
if created:
upd.update(first_referrer_domain=(ctx.get("referrer_domain") or "")[:200],
first_utm=_clean_utm(ctx.get("utm")),
first_landing_path=(ctx.get("path") or "")[:500],
last_active_date=now.date(), active_days=1)
elif not Visitor.objects.filter(pk=v.pk, last_active_date=now.date()).exists():
upd.update(last_active_date=now.date(), active_days=F("active_days") + 1)
if ip:
upd["last_ip"] = ip
g = _geo.resolve(ip)
if g:
upd.update(country=_geo_field(g, "country")[:64],
province=_geo_field(g, "province")[:64],
city=_geo_field(g, "city")[:64])
Visitor.objects.filter(pk=v.pk).update(**upd)
v.refresh_from_db()
return v
def _get_session(site, visitor, sid, ctx, occurred_at):
"""会话权威在 SDK;服务端兜底:同 session_id 距上事件>30min 切分后缀段。"""
idle = timedelta(milliseconds=C.SESSION_IDLE_MS)
sess = VisitSession.objects.filter(site=site, session_id=sid).order_by("-started_at").first()
if sess and occurred_at - sess.ended_at > idle:
suffix = 2 + VisitSession.objects.filter(
site=site, session_id__startswith=sid + "#").count()
sid = f"{sid}#{suffix}"
sess = None
if not sess:
try:
with transaction.atomic(): # savepoint:IntegrityError 后外层事务还能继续
sess = VisitSession.objects.create(
site=site, visitor=visitor, session_id=sid,
started_at=occurred_at, ended_at=occurred_at,
referrer_domain=(ctx.get("referrer_domain") or "")[:200],
utm=_clean_utm(ctx.get("utm")),
landing_path=(ctx.get("path") or "")[:500])
except IntegrityError: # 并发同 sid 同时切分出相同后缀撞 uq_site_session——重查复用
sess = VisitSession.objects.filter(site=site, session_id=sid).first()
if not sess:
raise
Visitor.objects.filter(pk=visitor.pk).update(visit_count=F("visit_count") + 1)
return sess
@transaction.atomic
def ingest_batch(site, payload: dict, ip: str | None = None, ua: str = "") -> dict:
now = timezone.now()
ctx = payload.get("context") or {}
events = payload.get("events") or []
result = {"accepted": 0, "duplicated": 0, "rejected": []}
if not events or len(events) > C.MAX_EVENTS_PER_BATCH:
return {"accepted": 0, "duplicated": 0,
"rejected": [{"reason": "batch_size_invalid"}]}
visitor = _touch_visitor(site, payload["visitor_id"], ctx, ip)
geo = _geo.resolve(ip) if ip else None
is_bot = any(k in (ua or "").lower() for k in C.BOT_UA_KEYWORDS)
if is_bot:
Visitor.objects.filter(pk=visitor.pk).update(is_bot=True)
ready, session_cache = [], {}
for i, ev in enumerate(events):
reason = validate_event(ev)
if reason:
result["rejected"].append({"index": i, "event_id": ev.get("event_id", ""),
"reason": reason})
continue
occ = ev["occurred_at"]
sid = payload["session_id"]
sess = session_cache.get(sid)
if not sess:
sess = _get_session(site, visitor, sid, ctx, occ)
session_cache[sid] = sess # 按原始 sid 存:切分后 sess.session_id 是 "s1#2",
# 若按它存,同批后续事件按 "s1" 查会 miss → 每条再切新会话
ready.append(Event(
site=site, visitor=visitor, session=sess,
event_id=ev["event_id"], event_name=ev["event_name"],
occurred_at=occ, path=(ev.get("path") or "")[:500],
title=(ev.get("title") or "")[:200],
properties=ev.get("properties") or {},
ip=ip, late=abs((now - occ).total_seconds()) > C.CLOCK_SKEW_LATE_SEC,
country=_geo_field(geo, "country"), province=_geo_field(geo, "province"),
city=_geo_field(geo, "city"), is_bot=is_bot))
if ready:
existed = set(Event.objects.filter(
site=site, event_id__in=[e.event_id for e in ready]
).values_list("event_id", flat=True))
new_events = [e for e in ready if e.event_id not in existed] # 重放命中的不参与聚合!
Event.objects.bulk_create(new_events, ignore_conflicts=True)
result["accepted"] = len(new_events)
result["duplicated"] = len(ready) - len(new_events)
for sess in {e.session for e in new_events}:
se = [e for e in new_events if e.session.pk == sess.pk]
new_end = max(x.occurred_at for x in se)
span_ms = int((new_end - min(x.occurred_at for x in se)).total_seconds() * 1000)
stay_add = sum(int(e.properties.get("active_ms", 0) or 0)
for e in se if e.event_name == "page_stay")
VisitSession.objects.filter(pk=sess.pk).update(
ended_at=Greatest(F("ended_at"), new_end), # 只前进不回退,防乱序批次改早结束时间
event_count=F("event_count") + len(se),
duration_ms=F("duration_ms") + span_ms,
stay_ms=F("stay_ms") + stay_add) # 有效停留单独累计,供访客"总停留"取数
return result