From 0ef01b0d4f0162703828bf23fe88fd0b29ea8a36 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=B5=85=E5=BF=B5?= Date: Fri, 18 Sep 2026 23:05:04 +0800 Subject: [PATCH] =?UTF-8?q?feat(server):=20=E8=AF=A2=E7=9B=98=E6=BC=8F?= =?UTF-8?q?=E6=96=97API=E2=80=94=E2=80=94=E4=BA=94=E7=BA=A7=E7=8B=AC?= =?UTF-8?q?=E7=AB=8Bdistinct=E4=BC=9A=E8=AF=9D=E8=AE=A1=E6=95=B0/=E6=8E=92?= =?UTF-8?q?=E9=99=A4=E6=9C=BA=E5=99=A8=E4=BA=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Code --- server/config/urls.py | 3 +- server/tracker/api/dashboard.py | 57 +++++++++++++++- server/tracker/tests/test_dashboard.py | 91 ++++++++++++++++++++++++++ 3 files changed, 149 insertions(+), 2 deletions(-) diff --git a/server/config/urls.py b/server/config/urls.py index 4b934b8..0fd6001 100644 --- a/server/config/urls.py +++ b/server/config/urls.py @@ -2,7 +2,7 @@ from django.urls import path from tracker.api.collect import CollectBatchView from tracker.api.auth import LoginView, RefreshView, MeView, UserCreateView from tracker.api.sites import SiteListView, SiteDetailView -from tracker.api.dashboard import OverviewView +from tracker.api.dashboard import FunnelSummaryView, OverviewView from tracker.api.visitors import (VisitorDetailView, VisitorListView, VisitorRawBatchesView, VisitorSessionView, VisitorTimelineView) @@ -19,6 +19,7 @@ urlpatterns += [ path("api/admin/sites", SiteListView.as_view()), path("api/admin/sites/", SiteDetailView.as_view()), path("api/admin/dashboard/overview", OverviewView.as_view()), + path("api/admin/funnel-summary", FunnelSummaryView.as_view()), path("api/admin/visitors", VisitorListView.as_view()), path("api/admin/visitors/", VisitorDetailView.as_view()), path("api/admin/visitors//timeline", VisitorTimelineView.as_view()), diff --git a/server/tracker/api/dashboard.py b/server/tracker/api/dashboard.py index db436cf..fe04441 100644 --- a/server/tracker/api/dashboard.py +++ b/server/tracker/api/dashboard.py @@ -1,4 +1,4 @@ -"""数据总览接口(客迹视觉 V1:周期卡 + 趋势双线 + 渠道分布)。 +"""数据总览接口(客迹视觉 V1:周期卡 + 趋势双线 + 渠道分布 + 询盘漏斗)。 ?range=today|7d|30d(默认 7d,非法值回落默认)+ ?site=(可选,缺省聚合全部站点)。 - 周期卡四指标全口径排除爬虫,与上一个等长周期比较出 delta_pct(today 比昨天); @@ -9,6 +9,10 @@ - channels 按窗口内会话入口分类聚合,pct = 占已分类会话比(列表含 direct/unknown, 总和 ≈ 1;未重算出分类行的历史会话不计入)。 - 最近访问不在本接口做:前端调既有 /api/admin/visitors?page=1 取前 5 行。 + +询盘漏斗 /api/admin/funnel-summary(阶段B T3,spec §4.2):五级逐级独立 +distinct session 计数(非嵌套、无前置/顺序约束),全级 is_bot=False, +无百分比(前端渲染时自算)。 """ from datetime import datetime, time, timedelta @@ -23,6 +27,14 @@ from tracker.models import DailyStat, Event, Site, SourceClassification, VisitSe RANGE_DAYS = {"today": 1, "7d": 7, "30d": 30} CARD_KEYS = ("visitors", "visits", "avg_stay_ms", "returning") +# 询盘漏斗五级:(event_name, 中文 label)。label 服务端单源给出,前端不重复维护。 +FUNNEL_LEVELS = ( + ("page_view", "浏览"), + ("contact_click", "咨询点击"), + ("form_start", "表单开始"), + ("form_submit", "表单提交"), + ("form_submit_result", "邮件已发"), +) def _window_stats(site, start, end): @@ -177,3 +189,46 @@ class OverviewView(EnvelopeMixin, APIView): return [{"channel": r["channel"] or "unknown", "sessions": r["n"], "pct": round(r["n"] / total, 4) if total else 0.0} for r in rows] + + +class FunnelSummaryView(EnvelopeMixin, APIView): + """询盘漏斗:GET /api/admin/funnel-summary?days=N(默认 7,非法回落)+ ?site=。 + + 五级逐级独立 distinct session 计数(spec §4.2):每级各自数"发生过该事件的 + 会话数",非嵌套口径——后级不要求先发生前级事件,同级多次事件按会话去重只计 1。 + 全级 is_bot=False(事件行标记,与 ingest 同口径);无百分比(前端 T6 渲染时自算)。 + 时间窗与总览一致:上海自然日 [T-N+1, T+1),days=1 即"当日",与总览今日卡同口径。 + """ + + def get(self, request): + site = None + sid = request.query_params.get("site") + if sid: + site = Site.objects.filter(site_id=sid).first() + if not site: + return Response({"code": 404, "data": None, + "message": "site_not_found"}, status=404) + days = self._parse_days(request.query_params.get("days")) + today = timezone.localdate() + start = timezone.make_aware( + datetime.combine(today - timedelta(days=days - 1), time.min)) + end = timezone.make_aware( + datetime.combine(today + timedelta(days=1), time.min)) + levels = [] + for key, label in FUNNEL_LEVELS: + q = Event.objects.filter(is_bot=False, event_name=key, + occurred_at__gte=start, occurred_at__lt=end) + if site is not None: + q = q.filter(site=site) + levels.append({"key": key, "label": label, + "count": q.values("session").distinct().count()}) + return ok({"levels": levels}) + + @staticmethod + def _parse_days(raw, default=7): + """days 参数:正整数,非法/缺省/非正回落默认(沿总览 range 非法值回落语义)。""" + try: + days = int(raw) + except (TypeError, ValueError): + return default + return days if days >= 1 else default diff --git a/server/tracker/tests/test_dashboard.py b/server/tracker/tests/test_dashboard.py index d3d798e..07e2976 100644 --- a/server/tracker/tests/test_dashboard.py +++ b/server/tracker/tests/test_dashboard.py @@ -1,3 +1,5 @@ +import itertools + import pytest from datetime import timedelta from django.contrib.auth import get_user_model @@ -311,3 +313,92 @@ def test_overview_trend_pv_only_page_view(client, member_user): assert d["trend"][-1] == {"date": TODAY.isoformat(), "uv": 1, "sessions": 1} row = DailyStat.objects.get(site=site, date=TODAY) assert (row.pv, row.uv) == (1, 1) # 5 条事件仅 1 条 page_view 计 PV + + +# ---------------- 询盘漏斗(阶段B T3:五级独立 distinct session) ---------------- + +FUNNEL_URL = "/api/admin/funnel-summary" +FEV_SEQ = itertools.count() # 漏斗测试事件 id 计数器(跨用例唯一) + + +def _fev(site, visitor, session, name, at=NOW): + """造任意 event_name 的事件(既有 _events 只造 page_view)。""" + return Event.objects.create(site=site, visitor=visitor, session=session, + event_id=f"fe-{next(FEV_SEQ)}", event_name=name, occurred_at=at, path="/") + + +def test_funnel_summary_requires_auth(client): + assert client.get(FUNNEL_URL).status_code == 401 + + +def test_funnel_summary_independent_session_counting(client, member_user): + """五级逐级独立 distinct session(非嵌套非事件数):A 的 2 次 PV 只算 1 个 + "浏览"到达;B 未发生 form_start 仍计入 form_submit 级(无前置/顺序约束)。""" + _auth(client, member_user) + site = Site.objects.create(name="s", domains=["a.com"]) + va = _visitor(site, "fa", last_seen=timezone.now()) + vb = _visitor(site, "fb", last_seen=timezone.now()) + sa = _session(site, va, "fsa") + sb = _session(site, vb, "fsb") + _fev(site, va, sa, "page_view") + _fev(site, va, sa, "page_view") # 同会话第 2 次浏览:浏览级仍计 1 + _fev(site, va, sa, "contact_click") + _fev(site, vb, sb, "page_view") + _fev(site, vb, sb, "form_submit") # 跳过 form_start:提交级照计(独立口径) + + r = client.get(FUNNEL_URL) # 缺省 days=7 + assert r.status_code == 200 + levels = r.json()["data"]["levels"] + assert [lv["key"] for lv in levels] == ["page_view", "contact_click", + "form_start", "form_submit", + "form_submit_result"] + assert [lv["label"] for lv in levels] == ["浏览", "咨询点击", + "表单开始", "表单提交", "邮件已发"] + assert [lv["count"] for lv in levels] == [2, 1, 0, 1, 0] + + +def test_funnel_summary_excludes_bots(client, member_user): + """is_bot=True 的事件不计入任何级(与总览爬虫口径一致)。""" + _auth(client, member_user) + site = Site.objects.create(name="s", domains=["a.com"]) + vh = _visitor(site, "fh", last_seen=timezone.now()) + sh = _session(site, vh, "fsh") + _fev(site, vh, sh, "page_view") + _fev(site, vh, sh, "contact_click") + bot = _visitor(site, "fbot", last_seen=timezone.now()) + Visitor.objects.filter(pk=bot.pk).update(is_bot=True) + sbot = _session(site, bot, "fsbot") + for name in ("page_view", "contact_click", "form_start", + "form_submit", "form_submit_result"): # bot 五级全打:任何级都不进 + _fev(site, bot, sbot, name) + Event.objects.filter(visitor=bot).update(is_bot=True) # 与 ingest 同口径:事件行同步标记 + + levels = client.get(FUNNEL_URL).json()["data"]["levels"] + assert [lv["count"] for lv in levels] == [1, 1, 0, 0, 0] + + +def test_funnel_summary_days_window_site_filter_and_404(client, member_user): + """days 窗口为上海自然日 [T-N+1, T+1)(与总览一致:days=1 即"当日"); + site 参数按条件过滤;非法 site 404 信封同总览。""" + _auth(client, member_user) + site_a = Site.objects.create(name="a", domains=["a.com"]) + site_b = Site.objects.create(name="b", domains=["b.com"]) + ya = NOW - timedelta(days=1) # 昨天同一本地时刻,必落上海昨日窗口 + for site, vid, sid, at in ((site_a, "wa", "wsa", NOW), (site_a, "wy", "wsy", ya), + (site_b, "wb", "wsb", NOW)): + v = _visitor(site, vid, last_seen=timezone.now()) + s = _session(site, v, sid, at=at) + _fev(site, v, s, "page_view", at=at) + + d = client.get(FUNNEL_URL).json()["data"] # 缺省 7d 聚合全部站点 + assert d["levels"][0]["count"] == 3 + d = client.get(FUNNEL_URL, {"days": 1}).json()["data"] # 今日窗口:昨天不计 + assert d["levels"][0]["count"] == 2 + d = client.get(FUNNEL_URL, {"days": "abc"}).json()["data"] # 非法值回落 7d + assert d["levels"][0]["count"] == 3 + d = client.get(FUNNEL_URL, {"site": site_b.site_id}).json()["data"] + assert d["levels"][0]["count"] == 1 + r = client.get(FUNNEL_URL, {"site": "site_nope"}) + assert r.status_code == 404 + body = r.json() + assert body["code"] == 404 and body["data"] is None and body["message"] == "site_not_found"