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:
@@ -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",
|
||||
|
||||
@@ -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]})
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user