From 50faa73cf910b1e62e330f52abd26347a02aebf2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=B5=85=E5=BF=B5?= Date: Wed, 16 Sep 2026 18:19:31 +0800 Subject: [PATCH] =?UTF-8?q?feat(admin):=20=E8=AE=BF=E5=AE=A2=E8=AF=A6?= =?UTF-8?q?=E6=83=85=E9=A1=B5=E6=8C=89=E5=AE=A2=E8=BF=B9=E8=A7=86=E8=A7=89?= =?UTF-8?q?V1=E9=87=8D=E8=AE=BE=E8=AE=A1=EF=BC=88=E5=A4=B4=E9=83=A8?= =?UTF-8?q?=E5=8D=A1/=E7=BB=9F=E8=AE=A1=E5=8D=A1/=E6=97=B6=E9=97=B4?= =?UTF-8?q?=E7=BA=BF/=E6=9D=A5=E6=BA=90=E8=AF=81=E6=8D=AE=E4=B8=89?= =?UTF-8?q?=E5=8F=A3=E5=BE=84/=E8=AE=BF=E9=97=AE=E7=8E=AF=E5=A2=83?= =?UTF-8?q?=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Code --- admin/src/api/tracker/visitors.ts | 17 +- admin/src/utils/tracker/index.ts | 4 +- .../visitor/components/EventTimeline.vue | 176 ++- admin/src/views/tracker/visitor/detail.vue | 1316 ++++++++++++----- server/tracker/api/visitors.py | 41 +- server/tracker/tests/test_visitors.py | 55 +- 6 files changed, 1152 insertions(+), 457 deletions(-) diff --git a/admin/src/api/tracker/visitors.ts b/admin/src/api/tracker/visitors.ts index cde3b83..0e9491c 100644 --- a/admin/src/api/tracker/visitors.ts +++ b/admin/src/api/tracker/visitors.ts @@ -76,9 +76,22 @@ export interface AttributionInfo { session_id?: string | null } -/** 详情归因块:first_touch 主口径 + 近几次会话入口识别(旧数据未重算时 first_touch 为 null) */ +/** 首次可观测来源占位(无 first_touch 分类行的历史访客,后端仅给 channel/resolution 两字段) */ +export interface ObservablePlaceholder { + channel: 'unknown' + resolution: 'unknown' +} + +/** 详情归因块:first_touch 主口径 + 来源证据三口径 + 近几次会话入口识别 */ export interface AttributionBlock { + /** 首触分类(旧数据未重算时为 null) */ first_touch: AttributionInfo | null + /** 首次可观测来源:first_touch 分类原样(含 direct/unknown);无分类行 → unknown 占位 */ + first_observable: AttributionInfo | ObservablePlaceholder + /** 首次可识别来源:first_touch 可识别时用之,否则最早一条可识别 session_entry;无则 null */ + first_identifiable: AttributionInfo | null + /** 本次会话来源:最新一条 session_entry;无则 null */ + latest_session: AttributionInfo | null session_entries: AttributionInfo[] } @@ -90,6 +103,8 @@ export interface VisitorDetail extends VisitorRow { last_ip: string | null first_utm: Record first_referrer_domain: string + /** page_view 事件数(page_stay 是停留信号不并计) */ + pages_viewed: number /** 来源归因块(first_touch=null 时前端回落 first_source 展示) */ attribution: AttributionBlock sessions: SessionSummary[] diff --git a/admin/src/utils/tracker/index.ts b/admin/src/utils/tracker/index.ts index f29283b..f0ebdb0 100644 --- a/admin/src/utils/tracker/index.ts +++ b/admin/src/utils/tracker/index.ts @@ -47,7 +47,7 @@ export const CHANNEL_LABELS: Record - + + + diff --git a/admin/src/views/tracker/visitor/detail.vue b/admin/src/views/tracker/visitor/detail.vue index 7dc88a9..face236 100644 --- a/admin/src/views/tracker/visitor/detail.vue +++ b/admin/src/views/tracker/visitor/detail.vue @@ -1,237 +1,237 @@ - + diff --git a/server/tracker/api/visitors.py b/server/tracker/api/visitors.py index eb63605..c9472e6 100644 --- a/server/tracker/api/visitors.py +++ b/server/tracker/api/visitors.py @@ -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]}) diff --git a/server/tracker/tests/test_visitors.py b/server/tracker/tests/test_visitors.py index 050767c..751a350 100644 --- a/server/tracker/tests/test_visitors.py +++ b/server/tracker/tests/test_visitors.py @@ -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