feat(server): 询盘漏斗API——五级独立distinct会话计数/排除机器人
Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
@@ -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()),
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user