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
+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"])