Files
fangke/server/tracker/api/dashboard.py
T

235 lines
12 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""数据总览接口(客迹视觉 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