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