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