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
+183
View File
@@ -0,0 +1,183 @@
"""来源归因规则引擎(tracker/services/attribution.classify)。
纯函数用例(无 DB)+ 重算命令/入库集成为带 db 标记的独立用例。
诚实状态语义:resolved=观察到 referrer 证据;label_only=仅 UTM 自述;
conflict=两证据矛盾并列;unknown=直接访问不猜测。
"""
import pytest
from django.core.management import call_command
from django.utils import timezone
from tracker.models import Site, SourceClassification, VisitSession, Visitor
from tracker.services.attribution import classify
NOW = timezone.now()
# ---------------------------------------------------------------- 引擎纯函数
def test_bing_search_term_extracted():
r = classify(referrer_url="https://www.bing.com/search?q=%E6%8C%96%E6%8E%98%E6%9C%BA&m=1",
landing_path="/pricing")
assert r["channel"] == "natural_search"
assert r["platform"] == "Bing"
assert r["search_term"] == "挖掘机"
assert r["evidence_kind"] == "observed_referrer"
assert r["resolution"] == "resolved"
def test_google_never_fabricates_search_term():
r = classify(referrer_url="https://www.google.com/search?q=cats")
assert r["channel"] == "natural_search" and r["platform"] == "Google"
assert r["search_term"] == "" # Google 不在 SEARCH_TERM_PARAMS 表 = 恒空,绝不虚构
assert r["variant"] == "" # google.com → TLD "com" 不显示
def test_google_country_tld_as_variant():
r = classify(referrer_url="https://www.google.co.ru/search?q=x")
assert r["platform"] == "Google" and r["variant"] == "ru"
def test_baidu_wd_chinese_term_and_word_fallback():
r = classify(referrer_url="https://www.baidu.com/s?wd=挖掘机价格")
assert r["platform"] == "百度" and r["search_term"] == "挖掘机价格"
r2 = classify(referrer_url="https://m.baidu.com/s?word=租赁") # 子域 + word 参数兜底
assert r2["platform"] == "百度" and r2["search_term"] == "租赁"
def test_chatgpt_referral():
r = classify(referrer_url="https://chatgpt.com/c/abc")
assert r["channel"] == "ai_referral" and r["platform"] == "ChatGPT"
assert r["resolution"] == "resolved"
r2 = classify(referrer_url="https://chat.chatgpt.com/") # 子域同样命中
assert r2["channel"] == "ai_referral" and r2["platform"] == "ChatGPT"
def test_social_twitter_short_link():
r = classify(referrer_url="https://t.co/AbCdEf")
assert r["channel"] == "social" and r["platform"] == "Twitter/X"
def test_generic_referral_keeps_host_as_platform():
r = classify(referrer_url="https://blog.example.com/post/1")
assert r["channel"] == "referral" and r["platform"] == "blog.example.com"
assert r["evidence_kind"] == "observed_referrer" and r["resolution"] == "resolved"
def test_paid_click_param_wins_over_natural_search():
r = classify(referrer_url="https://www.bing.com/search?q=x",
landing_path="/offer?gclid=EAIaIQ")
assert r["channel"] == "paid_search" and r["platform"] == "Google Ads" # 付费优先于自然
assert r["detail"].get("click_param") == "gclid"
r2 = classify(landing_path="/?fbclid=IwAR0") # 仅落地页带点击参数
assert r2["channel"] == "paid_search" and r2["platform"] == "Meta Ads"
def test_paid_param_in_referrer_url():
r = classify(referrer_url="https://example.com/ad?msclkid=xyz", landing_path="/")
assert r["channel"] == "paid_search" and r["platform"] == "Bing Ads"
def test_micromessenger_ua_without_referrer_is_in_app():
r = classify(ua="Mozilla/5.0 (Linux; Android 14) MicroMessenger/8.0.47")
assert r["channel"] == "in_app" and r["platform"] == "微信"
assert r["resolution"] == "resolved"
def test_in_app_marker_overlays_existing_source_in_detail():
r = classify(referrer_url="https://www.bing.com/search?q=x",
ua="Mozilla/5.0 ... MicroMessenger/8.0.47")
assert r["channel"] == "natural_search" # 来源结论不被 UA 覆盖
assert r["detail"].get("in_app") == "微信" # 应用内浏览器作为 surface 叠加进 detail
def test_internal_referrer_treated_as_no_signal():
r = classify(referrer_url="https://www.a.com/other-page", internal_domains=["a.com"])
assert r["channel"] == "direct" and r["resolution"] == "unknown"
r2 = classify(referrer_url="https://sub.a.com/x", utm={"utm_source": "newsletter"},
internal_domains=["a.com"]) # 本站跳转后仍带 UTM → 仅剩标签证据
assert r2["resolution"] == "label_only" and r2["platform"] == "newsletter"
def test_utm_matching_referrer_upgrades_to_combined():
r = classify(referrer_url="https://www.bing.com/search?q=x", utm={"utm_source": "bing"})
assert r["channel"] == "natural_search" and r["evidence_kind"] == "combined"
def test_utm_contradicting_referrer_is_conflict_with_both_evidences():
r = classify(referrer_url="https://facebook.com/l/abc", utm={"utm_source": "google"})
assert r["channel"] == "social" and r["platform"] == "Facebook" # 观察证据优先于自述标签
assert r["resolution"] == "conflict"
assert r["evidence_kind"] == "observed_referrer"
ref_ev, utm_ev = r["detail"]["referrer_evidence"], r["detail"]["utm_evidence"]
assert ref_ev["platform"] == "Facebook" and ref_ev["channel"] == "social"
assert utm_ev["platform"] == "google" and utm_ev["evidence_kind"] == "utm_label"
def test_utm_only_is_label_only():
r = classify(utm={"utm_source": "newsletter"})
assert r["channel"] == "referral" # 未知标签 → 有名 referral,但状态诚实标注
assert r["platform"] == "newsletter"
assert r["resolution"] == "label_only" and r["evidence_kind"] == "utm_label"
r2 = classify(utm={"utm_source": "baidu"}) # 已知引擎标签 → 归入对应渠道(仍 label_only)
assert r2["channel"] == "natural_search" and r2["platform"] == "baidu"
assert r2["resolution"] == "label_only"
def test_empty_everything_is_direct_unknown():
r = classify()
assert r == {"channel": "direct", "platform": "", "variant": "",
"search_term": "", "evidence_kind": "", "resolution": "unknown",
"detail": {}}
def test_search_term_cleaned_and_truncated():
long = "词" * 150
r = classify(referrer_url=f"https://www.bing.com/search?q=%20%20{long}%20")
assert r["search_term"] == "词" * 100 # strip + 截 100
r2 = classify(referrer_url="https://www.bing.com/search?q=a%00b%01c") # 控制字符剔除
assert r2["search_term"] == "abc"
def test_garbage_referrer_url_does_not_crash():
r = classify(referrer_url="http://[invalid", landing_path="/x")
assert r["channel"] in ("referral", "direct") # 不崩,落到兜底分支即可
# ------------------------------------------------------- 入库 + 重算集成
@pytest.mark.django_db
def test_ingest_writes_first_touch_and_session_entry():
from tracker.services.ingest import ingest_batch
site = Site.objects.create(name="s", domains=["a.com"])
payload = {"visitor_id": "v1", "session_id": "s1", "context": {
"referrer_domain": "www.bing.com", "utm": {}, "path": "/landing"},
"events": [{"event_id": "e1", "event_name": "page_view", "occurred_at": NOW,
"path": "/landing", "title": "T",
"properties": {"referrer": "https://www.bing.com/search?q=%E6%8C%96%E6%8E%98%E6%9C%BA"}}]}
ingest_batch(site, payload, ip="114.114.114.114", ua="Mozilla/5.0")
ft = SourceClassification.objects.get(site=site, visitor__visitor_id="v1", scope="first_touch")
assert ft.session is None
assert ft.channel == "natural_search" and ft.platform == "Bing"
assert ft.search_term == "挖掘机" and ft.rule_version == "v1"
se = SourceClassification.objects.get(site=site, scope="session_entry")
assert se.session.session_id == "s1"
assert se.channel == "natural_search" and se.platform == "Bing"
@pytest.mark.django_db
def test_recompute_attribution_idempotent():
from tracker.services.ingest import ingest_batch
site = Site.objects.create(name="s", domains=["a.com"])
payload = {"visitor_id": "v1", "session_id": "s1", "context": {
"referrer_domain": "www.bing.com", "utm": {}, "path": "/landing"},
"events": [{"event_id": "e1", "event_name": "page_view", "occurred_at": NOW,
"path": "/landing", "title": "T",
"properties": {"referrer": "https://www.bing.com/search?q=ok"}}]}
ingest_batch(site, payload, ip="114.114.114.114", ua="Mozilla/5.0")
SourceClassification.objects.all().delete() # 清掉增量写入,验证重算可全量重建
call_command("recompute_attribution", stdout=None)
n1 = SourceClassification.objects.count()
assert n1 == 2 # first_touch + session_entry 各一
ft = SourceClassification.objects.get(scope="first_touch")
assert ft.channel == "natural_search" and ft.search_term == "ok"
call_command("recompute_attribution", stdout=None) # 第二遍:upsert 不重复不漂移
assert SourceClassification.objects.count() == n1
+28 -1
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
from tracker.services.stats import refresh_daily
pytestmark = pytest.mark.django_db
@@ -104,6 +104,33 @@ def test_overview_trend_uses_dailystat_history(client, member_user):
assert d["trend"][-1]["pv"] == 0 # 今天无事件,实时覆盖为 0
def test_overview_channels_aggregation(client, member_user):
"""channels:当天会话按 session_entry 分类的 channel 聚合(与 sources 并存,互不影响)。"""
_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())
s1 = _session(site, v1, "s1", referrer="www.bing.com")
s2 = _session(site, v1, "s2", referrer="www.bing.com")
s3 = _session(site, v2, "s3", referrer="chatgpt.com")
s4 = _session(site, v2, "s4", referrer="") # 直接访问
SourceClassification.objects.create(site=site, visitor=v1, session=s1,
scope="session_entry", channel="natural_search", platform="Bing")
SourceClassification.objects.create(site=site, visitor=v1, session=s2,
scope="session_entry", channel="natural_search", platform="Bing")
SourceClassification.objects.create(site=site, visitor=v2, session=s3,
scope="session_entry", channel="ai_referral", platform="ChatGPT")
SourceClassification.objects.create(site=site, visitor=v2, session=s4,
scope="session_entry", channel="direct", resolution="unknown")
_events(site, v1, s1, 2, "/", prefix="a")
d = client.get("/api/admin/dashboard/overview").json()["data"]
assert d["channels"] == [{"channel": "natural_search", "sessions": 2},
{"channel": "ai_referral", "sessions": 1},
{"channel": "direct", "sessions": 1}] # 倒序 top8,平局按名稳定
assert d["sources"][0] == {"source": "www.bing.com", "sessions": 2} # 原口径保留
def test_overview_site_filter_and_404(client, member_user):
_auth(client, member_user)
site_a = Site.objects.create(name="a", domains=["a.com"])
+20
View File
@@ -142,3 +142,23 @@ def test_context_timezone_persisted_to_visitor(db):
b["context"]["timezone"] = "Asia/Shanghai"
ingest_batch(site, b)
assert Visitor.objects.get(visitor_id="v1").timezone_name == "Asia/Shanghai"
# 15. 归因分类 best-effort:引擎抛异常不影响采集入库(first_touch/session_entry 双路)
def test_attribution_failure_never_breaks_ingest(db, monkeypatch):
from tracker.services import ingest as ingest_mod
site = Site.objects.create(name="s", domains=["a.com"])
monkeypatch.setattr(ingest_mod, "classify", lambda **kw: (_ for _ in ()).throw(RuntimeError("boom")))
r = ingest_batch(site, _batch([_ev(1)]))
assert r["accepted"] == 1 and Event.objects.count() == 1
assert Visitor.objects.count() == 1 and VisitSession.objects.count() == 1
# 16. 首触分类用本批第一条 page_view 的完整 referrer(搜索词可恢复),非 ctx 裸域名
def test_first_touch_classification_uses_full_pageview_referrer(db):
from tracker.models import SourceClassification
site = Site.objects.create(name="s", domains=["a.com"])
b = _batch([_ev(1, properties={"referrer": "https://m.baidu.com/s?wd=挖掘机"})])
b["context"]["utm"] = {} # 默认 fixture 带 utm_source=google,与百度矛盾 → conflict
ingest_batch(site, b)
ft = SourceClassification.objects.get(scope="first_touch")
assert ft.channel == "natural_search" and ft.platform == "百度"
assert ft.search_term == "挖掘机" and ft.resolution == "resolved"
+15
View File
@@ -37,6 +37,21 @@ def test_sanitize_url_drops_sensitive_params():
assert "email=" not in u and "token=" not in u and "utm_source=google" in u
assert "monkey=1" in u # fullmatch:含 key 子串的正常参数不误伤
def test_pageview_referrer_url_sanitized():
"""page_view 的 properties.referrer 是完整 URL:敏感参数整键剔除后写回。"""
ev = _ev(properties={"referrer": "https://www.bing.com/search?q=x&email=a@b.com&token=t"})
assert validate_event(ev) is None
assert ev["properties"]["referrer"] == "https://www.bing.com/search?q=x"
def test_pageview_referrer_truncated_after_reencode():
"""清洗重编码(中文 → %XX 九倍膨胀)可能超出原长:出门统一截 MAX_STR_LEN=500。"""
ev = _ev(properties={"referrer": "https://www.bing.com/search?q=" + "词" * 200})
assert validate_event(ev) is None # 原文 227 字符过长度门
ref = ev["properties"]["referrer"]
assert len(ref) == 500 and ref.startswith("https://www.bing.com/search?q=%E8%AF%8D")
def test_nested_sensitive_key_rejected():
assert validate_event(_ev(properties={"utm": {"utm_a": 1, "user_password": "x"}})) == "sensitive_field" # utm 在白名单内,嵌套敏感键仍须命中
+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"