feat(admin): 访客详情页按客迹视觉V1重设计(头部卡/统计卡/时间线/来源证据三口径/访问环境)
Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
@@ -208,6 +208,31 @@ class VisitorListView(EnvelopeMixin, APIView):
|
||||
return ok({"list": rows, "total": total, "page": page})
|
||||
|
||||
|
||||
def _source_evidence_triplet(visitor, ft, entries):
|
||||
"""来源证据三口径(详情页「来源证据」卡的数据面)。
|
||||
|
||||
first_observable 首次可观测来源:first_touch 分类原样(含 direct/unknown——诚实展示
|
||||
"首访只观察到直接访问");无分类行(历史数据未重算)→ unknown 占位。
|
||||
first_identifiable 首次可识别来源:first_touch 本身可识别(resolved/label_only/conflict,
|
||||
即除"没认出来"外的全部状态)直接用;否则回溯最早一条可识别的会话入口
|
||||
——首访 direct 不算"可识别",后续某天才带上可识别来源是常见轨迹。
|
||||
都没有 → None(前端显示 —,不虚构)。
|
||||
latest_session 本次会话来源:最新一条会话入口分类;无 → None。
|
||||
"""
|
||||
first_observable = _classification(ft) or {"channel": "unknown", "resolution": "unknown"}
|
||||
if ft and ft.resolution in ("resolved", "label_only", "conflict"):
|
||||
first_identifiable = _classification(ft)
|
||||
else:
|
||||
earliest = (SourceClassification.objects
|
||||
.filter(visitor=visitor, scope="session_entry",
|
||||
resolution__in=("resolved", "label_only"))
|
||||
.select_related("session")
|
||||
.order_by("session__started_at", "pk").first())
|
||||
first_identifiable = _classification(earliest, with_session=True)
|
||||
latest_session = _classification(entries[0], with_session=True) if entries else None
|
||||
return first_observable, first_identifiable, latest_session
|
||||
|
||||
|
||||
class VisitorDetailView(EnvelopeMixin, APIView):
|
||||
def get(self, request, visitor_id):
|
||||
visitor = _annotate(Visitor.objects.filter(visitor_id=visitor_id)
|
||||
@@ -222,16 +247,24 @@ class VisitorDetailView(EnvelopeMixin, APIView):
|
||||
cutoff = timezone.now() - ONLINE_WINDOW
|
||||
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])
|
||||
entries = list(SourceClassification.objects
|
||||
.filter(visitor=visitor, scope="session_entry")
|
||||
.select_related("session") # session_id 随行带出,免逐行查询
|
||||
.order_by("-session__started_at", "-pk")[:ATTR_SESSION_ENTRIES])
|
||||
first_observable, first_identifiable, latest_session = \
|
||||
_source_evidence_triplet(visitor, ft, entries)
|
||||
return ok({**_row(visitor, last_session, cutoff, ft),
|
||||
# 浏览页面数:page_view 事件计数(page_stay 不并计——它是停留信号不是浏览)
|
||||
"pages_viewed": Event.objects.filter(
|
||||
visitor=visitor, event_name="page_view").count(),
|
||||
"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),
|
||||
"first_observable": first_observable,
|
||||
"first_identifiable": first_identifiable,
|
||||
"latest_session": latest_session,
|
||||
"session_entries": [_classification(e, with_session=True)
|
||||
for e in entries]},
|
||||
"sessions": [_session_summary(s) for s in sessions]})
|
||||
|
||||
@@ -176,15 +176,21 @@ def test_detail_shape(client, member_user):
|
||||
v = _visitor(site, "v1", screen="1920x1080", timezone_name="Asia/Shanghai",
|
||||
last_ip="1.2.3.4", first_utm={"utm_source": "google"},
|
||||
first_referrer_domain="google.com", first_landing_path="/landing")
|
||||
_session(site, v, "s-old", at=NOW - timedelta(hours=2),
|
||||
referrer_domain="google.com", stay_ms=60000, duration_ms=300000, event_count=3)
|
||||
_session(site, v, "s-new", at=NOW, utm={"utm_source": "wechat"},
|
||||
landing_path="/new", stay_ms=30000, duration_ms=120000, event_count=2)
|
||||
s_old = _session(site, v, "s-old", at=NOW - timedelta(hours=2),
|
||||
referrer_domain="google.com", stay_ms=60000, duration_ms=300000,
|
||||
event_count=3)
|
||||
s_new = _session(site, v, "s-new", at=NOW, utm={"utm_source": "wechat"},
|
||||
landing_path="/new", stay_ms=30000, duration_ms=120000, event_count=2)
|
||||
_event(site, v, s_old, "e1", "page_view", NOW - timedelta(hours=2), path="/a")
|
||||
_event(site, v, s_old, "e2", "page_stay", NOW - timedelta(minutes=119),
|
||||
path="/a", active_ms=1000)
|
||||
_event(site, v, s_new, "e3", "page_view", NOW, path="/b")
|
||||
|
||||
r = client.get("/api/admin/visitors/v1")
|
||||
assert r.status_code == 200
|
||||
d = r.json()["data"]
|
||||
assert d["site_id"] == site.site_id and d["site_name"] == "s" # 详情同样输出站点归属
|
||||
assert d["pages_viewed"] == 2 # 只计 page_view;page_stay 是停留信号不并计
|
||||
assert d["screen"] == "1920x1080" and d["timezone_name"] == "Asia/Shanghai"
|
||||
assert d["last_ip"] == "1.2.3.4" and d["first_utm"] == {"utm_source": "google"}
|
||||
assert d["first_landing_path"] == "/landing" and d["first_referrer_domain"] == "google.com"
|
||||
@@ -382,3 +388,44 @@ def test_detail_attribution_block(client, member_user):
|
||||
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"
|
||||
|
||||
|
||||
def test_detail_source_evidence_triplet(client, member_user):
|
||||
"""三口径:首日 direct(不可识别)+ 次日 Google——first_observable=direct/unknown,
|
||||
first_identifiable 回溯到次日的可识别会话入口,latest_session=最新会话入口。"""
|
||||
_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(days=1)) # 首日直接访问
|
||||
s2 = _session(site, v, "s2", at=NOW, referrer_domain="google.com") # 次日 Google
|
||||
_classify(site, v, "first_touch", "direct", resolution="unknown")
|
||||
_classify(site, v, "session_entry", "direct", session=s1, resolution="unknown")
|
||||
_classify(site, v, "session_entry", "natural_search", "Google", 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"]
|
||||
fo = attr["first_observable"]
|
||||
assert fo["channel"] == "direct" and fo["resolution"] == "unknown" # 首访可观测=直接
|
||||
fi = attr["first_identifiable"]
|
||||
assert fi["channel"] == "natural_search" and fi["platform"] == "Google"
|
||||
assert fi["resolution"] == "resolved" and fi["session_id"] == "s2" # 回溯到次日入口
|
||||
ls = attr["latest_session"]
|
||||
assert ls["channel"] == "natural_search" and ls["session_id"] == "s2"
|
||||
|
||||
|
||||
def test_detail_source_evidence_no_classification(client, member_user):
|
||||
"""历史数据无任何分类行:first_observable 给 unknown 占位,其余两口径为 null。"""
|
||||
_auth(client, member_user)
|
||||
site = Site.objects.create(name="s", domains=["a.com"])
|
||||
v = _visitor(site, "v1")
|
||||
_session(site, v, "s1", referrer_domain="google.com")
|
||||
|
||||
r = client.get("/api/admin/visitors/v1")
|
||||
assert r.status_code == 200
|
||||
attr = r.json()["data"]["attribution"]
|
||||
assert attr["first_touch"] is None
|
||||
assert attr["first_observable"] == {"channel": "unknown", "resolution": "unknown"}
|
||||
assert attr["first_identifiable"] is None
|
||||
assert attr["latest_session"] is None
|
||||
|
||||
Reference in New Issue
Block a user