feat: 来源归因规则引擎(渠道/AI/搜索词/诚实状态/历史重算)

- constants 规则库 v1:AI/引擎/社交域名表、搜索词参数白名单、付费点击参数、
  应用内 UA 标记、CHANNELS 枚举;page_view 白名单开 nav_type/prerendering
- services/attribution.classify 纯函数:付费>AI>引擎>社交>referral 分层,
  UTM 证据叠加(combined/conflict/label_only),应用内 UA 叠加/兜底,
  引擎 TLD 国家 variant,搜索词白名单提取(Google 恒空不虚构)
- SourceClassification 派生表(迁移 0004):first_touch/session_entry 双 scope,
  rule_version 可追溯
- ingest 增量 best-effort:首访客/新会话时分类落库(savepoint+静默,
  失败不影响采集);validation 对 page_view referrer 完整 URL 参数清洗+截500
- recompute_attribution 命令:全量/按站幂等重算
- API:访客列表归因五字段+channel 筛选、详情 attribution 块(含最近5会话入口)、
  dashboard channels 聚合(sources 原口径保留);TS VisitorListParams 加 channel
- 测试 97 → 124(引擎20 + 集成/接口7)

Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
浅念
2026-09-16 17:03:15 +08:00
parent 8bde9b5b4b
commit 59de51c062
15 changed files with 816 additions and 15 deletions
+18 -1
View File
@@ -4,7 +4,7 @@ from django.utils import timezone
from rest_framework.views import APIView
from rest_framework.response import Response
from tracker.api.envelope import EnvelopeMixin, ok
from tracker.models import DailyStat, Event, Site, VisitSession, Visitor
from tracker.models import DailyStat, Event, Site, SourceClassification, VisitSession, Visitor
from tracker.services.stats import daily_counts
METRICS = ("pv", "uv", "sessions", "new_visitors")
@@ -31,6 +31,7 @@ class OverviewView(EnvelopeMixin, APIView):
return ok({"today": counts,
"trend": self._trend(site, today, counts),
"sources": self._today_sources(site, today),
"channels": self._today_channels(site, today),
"top_pages": self._today_pages(site, today)})
@staticmethod
@@ -75,6 +76,22 @@ class OverviewView(EnvelopeMixin, APIView):
.annotate(n=Count("id"))
.order_by("-n", "referrer_domain")[:8]] # 平局时次序跨请求稳定
def _today_channels(self, site, today):
"""当天会话按来源渠道聚合(session_entry 分类;sources 的升级版,原始口径保留)。
从分类表正向聚合 + Count(distinct session):无唯一约束下分类表偶发同键重复行
也不会把同一会话计两次。无分类行的会话(未重算的历史数据)不计入。"""
start, end = _day_bounds(today)
q = SourceClassification.objects.filter(
scope="session_entry",
session__visitor__is_bot=False,
session__started_at__gte=start, session__started_at__lt=end)
if site is not None:
q = q.filter(site=site)
return [{"channel": r["channel"] or "unknown", "sessions": r["n"]}
for r in q.values("channel")
.annotate(n=Count("session", distinct=True))
.order_by("-n", "channel")[:8]] # 平局时次序跨请求稳定
def _today_pages(self, site, today):
start, end = _day_bounds(today)
q = Event.objects.filter(is_bot=False, event_name="page_view",
+43 -5
View File
@@ -13,7 +13,7 @@ from rest_framework.response import Response
from rest_framework.views import APIView
from tracker.api.envelope import EnvelopeMixin, ok
from tracker.models import Event, RawBatch, Site, VisitSession, Visitor
from tracker.models import Event, RawBatch, Site, SourceClassification, VisitSession, Visitor
PAGE_SIZE = 20
RAW_PAGE_SIZE = 10 # 原始批次 body 较大,页小一些
@@ -21,6 +21,7 @@ TIMELINE_DEFAULT_DAYS = 30
TIMELINE_MAX_DAYS = 90
TIMELINE_MAX_EVENTS = 1000
ONLINE_WINDOW = timedelta(minutes=5) # 与 dashboard active_now 同口径
ATTR_SESSION_ENTRIES = 5 # 详情页归因块带出的最近会话入口分类数
def _source(referrer_domain, utm):
@@ -34,8 +35,9 @@ def _iso(dt):
return timezone.localtime(dt).isoformat() if dt else None
def _row(visitor, last_session, online_cutoff):
"""列表/详情共用行字段。last_source 依赖最近会话(由调用方批量取好传入,防 N+1)。"""
def _row(visitor, last_session, online_cutoff, ft=None):
"""列表/详情共用行字段。last_source 依赖最近会话(由调用方批量取好传入,防 N+1)。
channel 等归因五字段来自该访客 first_touch 分类(无分类行 → 空串,前端回落 first_source 展示)。"""
return {
"visitor_id": visitor.visitor_id,
# 站点归属:visitor.site 需随行 select_related("site"),否则逐行多一条查询
@@ -51,10 +53,28 @@ def _row(visitor, last_session, online_cutoff):
"first_source": _source(visitor.first_referrer_domain, visitor.first_utm),
"last_source": _source(last_session.referrer_domain, last_session.utm)
if last_session else "直接访问", # 无会话访客(仅采集异常场景)
"channel": ft.channel if ft else "",
"platform": ft.platform if ft else "",
"variant": ft.variant if ft else "",
"search_term": ft.search_term if ft else "",
"resolution": ft.resolution if ft else "",
"online": visitor.last_seen_at > online_cutoff, # Python 侧比对(last_seen_at 已随行取出)
}
def _classification(sc, with_session=False):
"""SourceClassification → API dict(详情归因块用,含证据可信度与规则版本)。"""
if not sc:
return None
d = {"channel": sc.channel, "platform": sc.platform, "variant": sc.variant,
"search_term": sc.search_term, "evidence_kind": sc.evidence_kind,
"resolution": sc.resolution, "detail": sc.detail or {},
"rule_version": sc.rule_version, "computed_at": _iso(sc.computed_at)}
if with_session:
d["session_id"] = sc.session.session_id if sc.session_id else None
return d
def _session_summary(s):
# duration_ms 仅内部字段不展示——前端时长一律用 stay_ms(有效停留口径)
return {"session_id": s.session_id,
@@ -96,6 +116,11 @@ def _page_visitors(qs, request):
if p.get("search"):
q = p["search"]
qs = qs.filter(Q(visitor_id__istartswith=q) | Q(last_ip__istartswith=q))
if p.get("channel"):
# 渠道筛选(值=CHANNELS 枚举):按 first_touch 分类内连接过滤。
# distinct 防御分类表脏重复行(无唯一约束,并发极端下可能同键两行)撑大 count。
qs = qs.filter(source_classifications__scope="first_touch",
source_classifications__channel=p["channel"]).distinct()
date_from = _parse_date(p.get("date_from"))
date_to = _parse_date(p.get("date_to"))
if date_from:
@@ -114,8 +139,12 @@ def _page_visitors(qs, request):
visitors = list(page_qs[(page - 1) * PAGE_SIZE: page * PAGE_SIZE])
latest = {s.pk: s for s in VisitSession.objects.filter(
pk__in=[v.latest_session_pk for v in visitors if v.latest_session_pk])}
# 归因字段批量取:整页一次 IN 查询(同 latest 的做法),不做 select_related N+1
fts = {sc.visitor_id: sc for sc in SourceClassification.objects.filter(
visitor__in=[v.pk for v in visitors], scope="first_touch")}
cutoff = timezone.now() - ONLINE_WINDOW
rows = [_row(v, latest.get(v.latest_session_pk), cutoff) for v in visitors]
rows = [_row(v, latest.get(v.latest_session_pk), cutoff, fts.get(v.pk))
for v in visitors]
return rows, total, page
@@ -190,11 +219,20 @@ class VisitorDetailView(EnvelopeMixin, APIView):
# 全量会话摘要(visitor,-started_at 索引);M1 访客会话量有限不分页
sessions = VisitSession.objects.filter(visitor=visitor).order_by("-started_at", "-pk")
cutoff = timezone.now() - ONLINE_WINDOW
return ok({**_row(visitor, last_session, cutoff),
ft = (SourceClassification.objects.filter(visitor=visitor, scope="first_touch")
.first())
entries = (SourceClassification.objects
.filter(visitor=visitor, scope="session_entry")
.select_related("session") # session_id 随行带出,免逐行查询
.order_by("-session__started_at", "-pk")[:ATTR_SESSION_ENTRIES])
return ok({**_row(visitor, last_session, cutoff, ft),
"screen": visitor.screen, "timezone_name": visitor.timezone_name,
"last_ip": visitor.last_ip, "first_utm": visitor.first_utm or {},
"first_landing_path": visitor.first_landing_path,
"first_referrer_domain": visitor.first_referrer_domain,
"attribution": {"first_touch": _classification(ft),
"session_entries": [_classification(e, with_session=True)
for e in entries]},
"sessions": [_session_summary(s) for s in sessions]})