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
+63 -2
View File
@@ -3,7 +3,7 @@ from datetime import timedelta
from django.contrib.auth import get_user_model
from django.utils import timezone
from rest_framework.test import APIClient
from tracker.models import Event, Site, VisitSession, Visitor
from tracker.models import Event, Site, SourceClassification, VisitSession, Visitor
pytestmark = pytest.mark.django_db
User = get_user_model()
@@ -77,7 +77,8 @@ def test_list_fields_and_default_bot_excluded(client, member_user):
assert set(row) == {"visitor_id", "site_id", "site_name", "country", "province", "city",
"device_type", "os", "browser", "language", "first_seen_at",
"last_seen_at", "visit_count", "active_days", "session_count",
"total_stay_ms", "first_source", "last_source", "online"}
"total_stay_ms", "first_source", "last_source", "online",
"channel", "platform", "variant", "search_term", "resolution"}
assert row["country"] == "中国" and row["os"] == "Windows" and row["browser"] == "Chrome"
assert row["session_count"] == 2
assert row["total_stay_ms"] == 150000 # 120000+30000,有效停留口径
@@ -319,3 +320,63 @@ def test_timeline_orphan_events_without_session(client, member_user):
sess = r.json()["data"]["days"][0]["sessions"][0]
assert sess["session_id"] is None and sess["started_at"] is None
assert [e["event_name"] for e in sess["events"]] == ["sdk_loaded", "page_stay"]
def _classify(site, visitor, scope, channel, platform="", session=None, **kw):
return SourceClassification.objects.create(
site=site, visitor=visitor, session=session, scope=scope,
channel=channel, platform=platform, **kw)
def test_list_attribution_fields_and_channel_filter(client, member_user):
_auth(client, member_user)
site = Site.objects.create(name="s", domains=["a.com"])
v1 = _visitor(site, "v1", last_seen=timezone.now())
v2 = _visitor(site, "v2", last_seen=timezone.now() - timedelta(hours=1))
v3 = _visitor(site, "v3", last_seen=timezone.now() - timedelta(hours=2)) # 无分类行
_classify(site, v1, "first_touch", "natural_search", "Bing",
search_term="挖掘机", evidence_kind="observed_referrer",
resolution="resolved")
_classify(site, v2, "first_touch", "ai_referral", "ChatGPT",
evidence_kind="observed_referrer", resolution="resolved")
d = client.get("/api/admin/visitors").json()["data"]
by_id = {x["visitor_id"]: x for x in d["list"]}
assert by_id["v1"]["channel"] == "natural_search"
assert by_id["v1"]["platform"] == "Bing" and by_id["v1"]["search_term"] == "挖掘机"
assert by_id["v1"]["variant"] == "" and by_id["v1"]["resolution"] == "resolved"
assert by_id["v3"]["channel"] == "" # 无分类行 → 空串(前端回落 first_source 展示)
d = client.get("/api/admin/visitors", {"channel": "natural_search"}).json()["data"]
assert d["total"] == 1 and [x["visitor_id"] for x in d["list"]] == ["v1"]
d = client.get("/api/admin/visitors", {"channel": "direct"}).json()["data"]
assert d["total"] == 0 # 无分类访客不落进任何渠道桶
d = client.get("/api/admin/visitors", {"channel": "ai_referral"}).json()["data"]
assert [x["visitor_id"] for x in d["list"]] == ["v2"]
def test_detail_attribution_block(client, member_user):
_auth(client, member_user)
site = Site.objects.create(name="s", domains=["a.com"])
v = _visitor(site, "v1")
s1 = _session(site, v, "s1", at=NOW - timedelta(hours=2),
referrer_domain="www.bing.com")
s2 = _session(site, v, "s2", at=NOW, referrer_domain="chatgpt.com")
_classify(site, v, "first_touch", "natural_search", "Bing",
search_term="挖掘机", evidence_kind="observed_referrer",
resolution="resolved")
_classify(site, v, "session_entry", "natural_search", "Bing",
session=s1, evidence_kind="observed_referrer", resolution="resolved")
_classify(site, v, "session_entry", "ai_referral", "ChatGPT",
session=s2, evidence_kind="observed_referrer", resolution="resolved")
r = client.get("/api/admin/visitors/v1")
assert r.status_code == 200
attr = r.json()["data"]["attribution"]
ft = attr["first_touch"]
assert ft["channel"] == "natural_search" and ft["platform"] == "Bing"
assert ft["search_term"] == "挖掘机" and ft["resolution"] == "resolved"
assert ft["evidence_kind"] == "observed_referrer" and ft["rule_version"] == "v1"
assert ft["computed_at"] and ft["detail"] == {}
assert [e["session_id"] for e in attr["session_entries"]] == ["s2", "s1"] # 最近在前
assert attr["session_entries"][0]["channel"] == "ai_referral"