feat: 总览聚合接口
GET /api/admin/dashboard/overview?site=<site_id>(site 可选,缺省聚合全部站点): - today:daily_counts 实时四指标(与 refresh_daily 共享口径)+ active_now (Visitor.last_seen_at 5 分钟窗口,走 site,-last_seen_at 索引) - trend:DailyStat 近 30 天 values(date).annotate(Sum) 跨站点求和, 缺日补零,今天一位用实时值覆盖(聚合作业未跑也不缺当日) - sources/top_pages:当天上海自然日窗口 Top8,空来源归"直接访问" - ?site= 不存在 → 404 信封 site_not_found;未认证 401(默认权限) 测试 4 个(认证/数值与形态/DailyStat 历史/站点过滤与 404),全套 79 passed。 Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,85 @@
|
||||
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 tracker.api.envelope import EnvelopeMixin, ok
|
||||
from tracker.models import DailyStat, Event, Site, VisitSession, Visitor
|
||||
from tracker.services.stats import daily_counts
|
||||
|
||||
METRICS = ("pv", "uv", "sessions", "new_visitors")
|
||||
|
||||
|
||||
def _day_bounds(date):
|
||||
"""上海自然日窗口,与 daily_counts 同款 make_aware。"""
|
||||
start = timezone.make_aware(datetime.combine(date, time.min))
|
||||
return start, start + timedelta(days=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)
|
||||
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),
|
||||
"top_pages": self._today_pages(site, today)})
|
||||
|
||||
@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, today_counts):
|
||||
"""DailyStat 近 30 天(缺日补零;今天一位用 daily_counts 实时值覆盖——聚合作业未跑也不缺当日)。"""
|
||||
days = 30
|
||||
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"))}
|
||||
out = []
|
||||
for i in range(days):
|
||||
date = start + timedelta(days=i)
|
||||
if date == today:
|
||||
out.append({"date": date.isoformat(),
|
||||
**{k: today_counts[k] for k in METRICS}})
|
||||
else:
|
||||
r = rows.get(date, {})
|
||||
out.append({"date": date.isoformat(),
|
||||
**{k: r.get(k, 0) for k in METRICS}})
|
||||
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")[: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")[:8]]
|
||||
Reference in New Issue
Block a user