feat(admin): 数据总览按客迹视觉V1重做(周期卡/趋势双线/渠道条形/最近访问)
Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
@@ -1,19 +1,61 @@
|
||||
"""数据总览接口(客迹视觉 V1:周期卡 + 趋势双线 + 渠道分布)。
|
||||
|
||||
?range=today|7d|30d(默认 7d,非法值回落默认)+ ?site=(可选,缺省聚合全部站点)。
|
||||
- 周期卡四指标全口径排除爬虫,与上一个等长周期比较出 delta_pct(today 比昨天);
|
||||
上期值为 0 时 delta 为 null(前端显示 —)。
|
||||
- 趋势为日粒度:历史位读 DailyStat 聚合(缺日补零),今天一位用 daily_counts 实时值
|
||||
(聚合作业未跑也不缺当日)——与 refresh_daily 共享口径(Task8 审查结论)。
|
||||
- channels 按窗口内会话入口分类聚合,pct = 占已分类会话比(列表含 direct/unknown,
|
||||
总和 ≈ 1;未重算出分类行的历史会话不计入)。
|
||||
- 最近访问不在本接口做:前端调既有 /api/admin/visitors?page=1 取前 5 行。
|
||||
"""
|
||||
from datetime import datetime, time, timedelta
|
||||
|
||||
from django.db.models import Count, Sum
|
||||
from django.utils import timezone
|
||||
from rest_framework.views import APIView
|
||||
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
|
||||
from tracker.services.stats import daily_counts
|
||||
|
||||
METRICS = ("pv", "uv", "sessions", "new_visitors")
|
||||
RANGE_DAYS = {"today": 1, "7d": 7, "30d": 30}
|
||||
CARD_KEYS = ("visitors", "visits", "avg_stay_ms", "returning")
|
||||
|
||||
|
||||
def _day_bounds(date):
|
||||
"""上海自然日窗口,与 daily_counts 同款 make_aware。"""
|
||||
start = timezone.make_aware(datetime.combine(date, time.min))
|
||||
return start, start + timedelta(days=1)
|
||||
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):
|
||||
@@ -25,14 +67,25 @@ class OverviewView(EnvelopeMixin, APIView):
|
||||
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()
|
||||
counts = daily_counts(site, today) # 与 refresh_daily 共享口径(Task8 审查结论)
|
||||
counts["active_now"] = self._active_now(site)
|
||||
return ok({"today": counts,
|
||||
"trend": self._trend(site, today, counts),
|
||||
"sources": self._today_sources(site, today),
|
||||
"channels": self._today_channels(site, today),
|
||||
"top_pages": self._today_pages(site, today)})
|
||||
# 当前窗口与上期等长窗口(上海自然日对齐:[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):
|
||||
@@ -43,62 +96,43 @@ class OverviewView(EnvelopeMixin, APIView):
|
||||
q = q.filter(site=site)
|
||||
return q.count()
|
||||
|
||||
def _trend(self, site, today, today_counts):
|
||||
"""DailyStat 近 30 天(缺日补零;今天一位用 daily_counts 实时值覆盖——聚合作业未跑也不缺当日)。"""
|
||||
days = 30
|
||||
def _trend(self, site, today, days):
|
||||
"""窗口每日 [{date, uv, sessions}]:历史位读 DailyStat(缺日补零),
|
||||
今天一位用 daily_counts 实时值覆盖——聚合作业未跑也不缺当日。"""
|
||||
start = today - timedelta(days=days - 1)
|
||||
qs = DailyStat.objects.filter(date__gte=start, date__lte=today)
|
||||
if site is not None:
|
||||
qs = qs.filter(site=site)
|
||||
rows = {r["date"]: r for r in qs.values("date").annotate(
|
||||
pv=Sum("pv"), uv=Sum("uv"), sessions=Sum("sessions"),
|
||||
new_visitors=Sum("new_visitors"))}
|
||||
uv=Sum("uv"), sessions=Sum("sessions"))}
|
||||
out = []
|
||||
for i in range(days):
|
||||
date = start + timedelta(days=i)
|
||||
if date == today:
|
||||
counts = daily_counts(site, today)
|
||||
out.append({"date": date.isoformat(),
|
||||
**{k: today_counts[k] for k in METRICS}})
|
||||
"uv": counts["uv"], "sessions": counts["sessions"]})
|
||||
else:
|
||||
r = rows.get(date, {})
|
||||
out.append({"date": date.isoformat(),
|
||||
**{k: r.get(k, 0) for k in METRICS}})
|
||||
"uv": r.get("uv") or 0, "sessions": r.get("sessions") or 0})
|
||||
return out
|
||||
|
||||
def _today_sources(self, site, today):
|
||||
start, end = _day_bounds(today)
|
||||
q = VisitSession.objects.filter(visitor__is_bot=False,
|
||||
started_at__gte=start, started_at__lt=end)
|
||||
if site is not None:
|
||||
q = q.filter(site=site)
|
||||
return [{"source": r["referrer_domain"] or "直接访问", "sessions": r["n"]}
|
||||
for r in q.values("referrer_domain")
|
||||
.annotate(n=Count("id"))
|
||||
.order_by("-n", "referrer_domain")[:8]] # 平局时次序跨请求稳定
|
||||
|
||||
def _today_channels(self, site, today):
|
||||
"""当天会话按来源渠道聚合(session_entry 分类;sources 的升级版,原始口径保留)。
|
||||
def _channels(self, site, start, end):
|
||||
"""窗口内会话按 session_entry 分类聚合(session.started_at 落窗)。
|
||||
从分类表正向聚合 + Count(distinct session):无唯一约束下分类表偶发同键重复行
|
||||
也不会把同一会话计两次。无分类行的会话(未重算的历史数据)不计入。"""
|
||||
start, end = _day_bounds(today)
|
||||
也不会把同一会话计两次。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)
|
||||
return [{"channel": r["channel"] or "unknown", "sessions": r["n"]}
|
||||
for r in q.values("channel")
|
||||
.annotate(n=Count("session", distinct=True))
|
||||
.order_by("-n", "channel")[:8]] # 平局时次序跨请求稳定
|
||||
|
||||
def _today_pages(self, site, today):
|
||||
start, end = _day_bounds(today)
|
||||
q = Event.objects.filter(is_bot=False, event_name="page_view",
|
||||
occurred_at__gte=start, occurred_at__lt=end)
|
||||
if site is not None:
|
||||
q = q.filter(site=site)
|
||||
return [{"path": r["path"], "pv": r["n"]}
|
||||
for r in q.values("path")
|
||||
.annotate(n=Count("id"))
|
||||
.order_by("-n", "path")[:8]] # 平局时次序跨请求稳定
|
||||
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]
|
||||
|
||||
Reference in New Issue
Block a user