feat(admin): 访客详情页按客迹视觉V1重设计(头部卡/统计卡/时间线/来源证据三口径/访问环境)

Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
浅念
2026-09-16 18:19:31 +08:00
parent 008d9277d8
commit 50faa73cf9
6 changed files with 1152 additions and 457 deletions
+37 -4
View File
@@ -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]})