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