"""数据总览接口(客迹视觉 V1:周期卡 + 趋势双线 + 渠道分布 + 询盘漏斗)。 ?range=today|7d|30d(默认 7d,非法值回落默认)+ ?site=(可选,缺省聚合全部站点)。 - 周期卡四指标全口径排除爬虫,与上一个等长周期比较出 delta_pct(today 比昨天); 上期值为 0 时 delta 为 null(前端显示 —)。 - 趋势为日粒度:窗口内 Event/VisitSession 按上海自然日实时聚合(缺日补零,今天一位 天然实时),不依赖定时聚合作业;口径与 refresh_daily/daily_counts 一致,且对有数据的 日顺手自愈回写 DailyStat(best-effort)——DailyStat 保留给历史归档消费者。 - 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 from django.db.models import Count, Q, Sum from django.db.models.functions import Trunc from django.utils import timezone from rest_framework.response import Response from rest_framework.views import APIView from tracker.api.envelope import EnvelopeMixin, ok from tracker.models import DailyStat, Event, Site, SourceClassification, VisitSession, Visitor 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): """窗口四指标(site=None 聚合全部站点;全口径排除爬虫)。 visitors 按 Event distinct visitor(与 daily_counts 的 uv 同源,跨日不重复计); visits 按会话 started_at 落窗;avg_stay_ms = Sum(stay_ms)/会话数(0 保护); returning = visitors - 窗口内新访客(first_seen_at 落窗;异常数据下限 0)。 """ ev = Event.objects.filter(is_bot=False, occurred_at__gte=start, occurred_at__lt=end) vs = VisitSession.objects.filter(visitor__is_bot=False, started_at__gte=start, started_at__lt=end) vis = Visitor.objects.filter(is_bot=False, first_seen_at__gte=start, first_seen_at__lt=end) if site is not None: ev = ev.filter(site=site) vs = vs.filter(site=site) vis = vis.filter(site=site) agg = vs.aggregate(n=Count("id"), stay=Sum("stay_ms")) sessions = agg["n"] visitors = ev.values("visitor").distinct().count() new_visitors = vis.count() return {"visitors": visitors, "visits": sessions, "avg_stay_ms": round((agg["stay"] or 0) / sessions) if sessions else 0, "returning": max(visitors - new_visitors, 0)} def _delta(cur, prev): """环比百分比(保留 1 位小数);上期值为 0 无意义 → None。""" if prev == 0: return None return round((cur - prev) / prev * 100, 1) class OverviewView(EnvelopeMixin, APIView): 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 = RANGE_DAYS.get(request.query_params.get("range", "7d"), 7) today = timezone.localdate() # 当前窗口与上期等长窗口(上海自然日对齐:[T-N+1, T+1) 与 [T-2N+1, T-N+1)) cur_start = timezone.make_aware( datetime.combine(today - timedelta(days=days - 1), time.min)) cur_end = timezone.make_aware( datetime.combine(today + timedelta(days=1), time.min)) prev_start = cur_start - timedelta(days=days) cur = _window_stats(site, cur_start, cur_end) prev = _window_stats(site, prev_start, cur_start) online = self._active_now(site) return ok({ "header": {"online": online, "active_now": online}, # active_now 兼容保留 "cards": {k: {"value": cur[k], "delta_pct": _delta(cur[k], prev[k])} for k in CARD_KEYS}, "trend": self._trend(site, today, days), "channels": self._channels(site, cur_start, cur_end), }) @staticmethod def _active_now(site): # 5 分钟内有行为的访客(last_seen_at 有 site,-last_seen_at 索引;不用无索引的 received_at) q = Visitor.objects.filter(is_bot=False, last_seen_at__gt=timezone.now() - timedelta(minutes=5)) if site is not None: q = q.filter(site=site) return q.count() def _trend(self, site, today, days): """窗口每日 [{date, uv, sessions}]:对窗口内 Event/VisitSession 按上海自然日 实时聚合(缺日补零,今天一位天然实时),不依赖定时聚合作业——DailyStat 为空 时历史位也是真实值。四指标口径与 daily_counts 一致;聚合出的按站按日数据顺手 自愈回写 DailyStat(best-effort,失败不影响响应;多站点按站拆行互不干扰), DailyStat 保留给历史归档消费者。 性能:窗口 ≤30 天,走 (site,occurred_at)/(site,started_at) 索引的按日聚合, 当前量级毫秒级。""" tz = timezone.get_current_timezone() 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)) ev = Event.objects.filter(is_bot=False, occurred_at__gte=start, occurred_at__lt=end) vs = VisitSession.objects.filter(visitor__is_bot=False, started_at__gte=start, started_at__lt=end) vis = Visitor.objects.filter(is_bot=False, first_seen_at__gte=start, first_seen_at__lt=end) if site is not None: ev = ev.filter(site=site) vs = vs.filter(site=site) vis = vis.filter(site=site) # 按站按日 Trunc 聚合(site=None 也拆站行:回写 DailyStat 需要站点维度; # visitor/session 天然属单站,跨站求和 = 全局聚合值) rows = {} # (site_id, date) -> {pv, uv, sessions, new_visitors},仅有数据的日 def _row(sid, day): return rows.setdefault( (sid, day), {"pv": 0, "uv": 0, "sessions": 0, "new_visitors": 0}) # pv 只计 page_view(与 daily_counts 同口径):page_stay 增量/sdk_loaded 不并计 for r in (ev.annotate(day=Trunc("occurred_at", "day", tzinfo=tz)) .values("site", "day") .annotate(pv=Count("id", filter=Q(event_name="page_view")), uv=Count("visitor", distinct=True))): row = _row(r["site"], r["day"].date()) row["pv"], row["uv"] = r["pv"], r["uv"] for r in (vs.annotate(day=Trunc("started_at", "day", tzinfo=tz)) .values("site", "day") .annotate(n=Count("id"))): _row(r["site"], r["day"].date())["sessions"] = r["n"] for r in (vis.annotate(day=Trunc("first_seen_at", "day", tzinfo=tz)) .values("site", "day") .annotate(n=Count("id"))): _row(r["site"], r["day"].date())["new_visitors"] = r["n"] # 自愈回写:仅窗口内有数据的日(空窗口不产生行),失败不影响趋势响应 for (sid, date), v in rows.items(): try: DailyStat.objects.update_or_create(site_id=sid, date=date, defaults=v) except Exception: # best-effort:回写失败不影响响应 pass out = [] first = today - timedelta(days=days - 1) for i in range(days): date = first + timedelta(days=i) day_rows = [v for (_, d), v in rows.items() if d == date] out.append({"date": date.isoformat(), "uv": sum(r["uv"] for r in day_rows), "sessions": sum(r["sessions"] for r in day_rows)}) return out def _channels(self, site, start, end): """窗口内会话按 session_entry 分类聚合(session.started_at 落窗)。 从分类表正向聚合 + Count(distinct session):无唯一约束下分类表偶发同键重复行 也不会把同一会话计两次。pct = 占已分类会话比(含 direct/unknown,总和 ≈ 1; 无分类行的会话——未重算的历史数据——不计入)。""" q = SourceClassification.objects.filter( scope="session_entry", session__visitor__is_bot=False, session__started_at__gte=start, session__started_at__lt=end) if site is not None: q = q.filter(site=site) rows = list(q.values("channel") .annotate(n=Count("session", distinct=True)) .order_by("-n", "channel")) # 平局时次序跨请求稳定 total = sum(r["n"] for r in rows) 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