Files
fangke/server/tracker/api/dashboard.py
T
浅念 a0f7bdb87e 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>
2026-09-15 14:53:01 +08:00

86 lines
3.7 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.
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]]