feat(server): 询盘漏斗API——五级独立distinct会话计数/排除机器人

Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
浅念
2026-09-18 23:05:04 +08:00
parent 52a8a72e3a
commit 0ef01b0d4f
3 changed files with 149 additions and 2 deletions
+2 -1
View File
@@ -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/<int:pk>", 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/<str:visitor_id>", VisitorDetailView.as_view()),
path("api/admin/visitors/<str:visitor_id>/timeline", VisitorTimelineView.as_view()),
+56 -1
View File
@@ -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
+91
View File
@@ -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"