"""历史来源分类全量重算(幂等可重复)。 规则引擎升级(constants.ATTRIBUTION_RULE_VERSION bump)后执行: python manage.py recompute_attribution # 全部站点 python manage.py recompute_attribution --site site_xxx 数据源约束:重算只能用"落库时保住的信号"—— - 访客首触:最早 page_view 的 properties.referrer/utm(无则档案字段兜底, referrer_domain 合成 URL 后搜索词已丢失,诚实降级)+ first_landing_path;UA 未存档 → 空。 - 会话入口:referrer_domain 合成 URL + utm + landing_path。 逐条 upsert(first_touch 键 site+visitor+scope;session_entry 键 site+session+scope), 跑两遍计数不变。 """ from django.core.management.base import BaseCommand from tracker import constants as C from tracker.models import Event, Site, SourceClassification, VisitSession, Visitor from tracker.services.attribution import classify def _first_touch_signals(visitor): """最早一条 page_view 的原始信号优先;旧数据无事件属性时回落访客档案字段。""" ev = (Event.objects.filter(visitor=visitor, event_name="page_view") .order_by("occurred_at", "pk").first()) props = (ev.properties or {}) if ev else {} referrer = props.get("referrer") if isinstance(props.get("referrer"), str) else "" domain = props.get("referrer_domain") or visitor.first_referrer_domain if not referrer and domain: referrer = f"https://{str(domain).strip().strip('/')}" utm = props.get("utm") if isinstance(props.get("utm"), dict) else None return referrer, (utm or visitor.first_utm or {}), visitor.first_landing_path def _upsert(site, visitor, session, scope, result): vals = {"channel": (result["channel"] or "")[:32], "platform": (result["platform"] or "")[:64], "variant": (result["variant"] or "")[:64], "search_term": (result["search_term"] or "")[:200], "evidence_kind": (result["evidence_kind"] or "")[:32], "resolution": (result["resolution"] or "")[:16], "detail": result["detail"] or {}, "session": session, "rule_version": C.ATTRIBUTION_RULE_VERSION} # 重算即升到当前规则版本 if session is None: SourceClassification.objects.update_or_create( site=site, visitor=visitor, scope=scope, defaults=vals) else: SourceClassification.objects.update_or_create( site=site, session=session, scope=scope, defaults={**vals, "visitor": visitor}) class Command(BaseCommand): help = "按当前规则版本全量重算来源分类(first_touch + session_entry,幂等)" def add_arguments(self, parser): parser.add_argument("--site", default=None, help="只重算指定 site_id(缺省全部站点)") def handle(self, *args, **opts): sites = Site.objects.all() if opts["site"]: sites = sites.filter(site_id=opts["site"]) if not sites.exists(): self.stdout.write(self.style.WARNING( f"site not found: {opts['site']}")) return v_total = s_total = 0 for site in sites: domains = site.domains or [] for visitor in Visitor.objects.filter(site=site).only("pk", "first_referrer_domain", "first_utm", "first_landing_path"): referrer, utm, landing = _first_touch_signals(visitor) _upsert(site, visitor, None, "first_touch", classify(referrer_url=referrer, utm=utm, landing_path=landing or "", internal_domains=domains)) v_total += 1 for sess in VisitSession.objects.filter(site=site).select_related("visitor"): referrer = f"https://{sess.referrer_domain.strip().strip('/')}" \ if sess.referrer_domain else "" _upsert(site, sess.visitor, sess, "session_entry", classify(referrer_url=referrer, utm=sess.utm or {}, landing_path=sess.landing_path or "", internal_domains=domains)) s_total += 1 self.stdout.write( f"recomputed attribution: {v_total} first_touch, {s_total} session_entry " f"(rule version {C.ATTRIBUTION_RULE_VERSION})")