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
+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"