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",