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:
浅念
2026-09-15 14:53:01 +08:00
parent cf925e8763
commit a0f7bdb87e
3 changed files with 222 additions and 0 deletions
+2
View File
@@ -2,6 +2,7 @@ from django.urls import path
from tracker.api.collect import CollectBatchView
from tracker.api.auth import LoginView, RefreshView, MeView, UserCreateView
from tracker.api.sites import SiteListView, SiteDetailView
from tracker.api.dashboard import OverviewView
urlpatterns = [
path("api/collect/batch", CollectBatchView.as_view()),
@@ -14,4 +15,5 @@ urlpatterns += [
path("api/admin/users", UserCreateView.as_view()),
path("api/admin/sites", SiteListView.as_view()),
path("api/admin/sites/<int:pk>", SiteDetailView.as_view()),
path("api/admin/dashboard/overview", OverviewView.as_view()),
]
+85
View File
@@ -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]]
+135
View File
@@ -0,0 +1,135 @@
import pytest
from datetime import timedelta
from django.contrib.auth import get_user_model
from django.utils import timezone
from rest_framework.test import APIClient
from tracker.models import Event, Site, VisitSession, Visitor
from tracker.services.stats import refresh_daily
pytestmark = pytest.mark.django_db
User = get_user_model()
# 用上海本地时刻做基准:日界是上海自然日(make_aware/localdate),
# 直接用 UTC 的 now 在本地 00:00-08:00 之间跑测试会错一天
NOW = timezone.localtime(timezone.now())
TODAY = NOW.date()
YESTERDAY = TODAY - timedelta(days=1)
@pytest.fixture
def client(): # 覆盖 pytest-django 的 Django Client——认证用例需要 credentials()
return APIClient()
@pytest.fixture
def member_user():
return User.objects.create_user(username="member", password="memberpass123")
def _auth(client, user):
from rest_framework_simplejwt.tokens import RefreshToken
token = RefreshToken.for_user(user)
client.credentials(HTTP_AUTHORIZATION=f"Bearer {token.access_token}")
def _visitor(site, vid, last_seen=None):
v = Visitor.objects.create(site=site, visitor_id=vid)
if last_seen is not None: # last_seen_at 是 auto_now_add,只能 queryset.update 绕开
Visitor.objects.filter(pk=v.pk).update(last_seen_at=last_seen)
return v
def _session(site, visitor, sid, referrer="", at=NOW):
return VisitSession.objects.create(site=site, visitor=visitor, session_id=sid,
started_at=at, ended_at=at + timedelta(minutes=5), referrer_domain=referrer)
def _events(site, visitor, session, n, path, at=NOW, prefix="e"):
for i in range(n):
Event.objects.create(site=site, visitor=visitor, session=session,
event_id=f"{prefix}-{i}", event_name="page_view",
occurred_at=at + timedelta(seconds=i), path=path)
def test_overview_requires_auth(client):
assert client.get("/api/admin/dashboard/overview").status_code == 401
def test_overview_shape_and_numbers(client, member_user):
_auth(client, member_user)
site = Site.objects.create(name="s", domains=["a.com"])
v1 = _visitor(site, "v1", last_seen=timezone.now()) # 活跃(5 分钟内)
v2 = _visitor(site, "v2", last_seen=timezone.now() - timedelta(hours=1)) # 不活跃
s1 = _session(site, v1, "s1", referrer="google.com")
s2 = _session(site, v2, "s2", referrer="google.com")
s3 = _session(site, v1, "s3", referrer="") # 空来源 → 直接访问
_events(site, v1, s1, 6, "/pricing", prefix="p") # /pricing x6
_events(site, v1, s3, 2, "/", prefix="r") # / x2
_events(site, v2, s2, 2, "/docs", prefix="d") # /docs x2
r = client.get("/api/admin/dashboard/overview")
assert r.status_code == 200
d = r.json()["data"]
assert d["today"] == {"pv": 10, "uv": 2, "sessions": 3,
"new_visitors": 2, "active_now": 1}
trend = d["trend"]
assert len(trend) == 30
assert trend[-1]["date"] == TODAY.isoformat()
assert trend[-1]["pv"] == 10 # 今天位用实时值
assert trend[-2]["pv"] == 0 # 昨天无 DailyStat → 补零
assert d["sources"][0] == {"source": "google.com", "sessions": 2}
assert d["sources"][-1] == {"source": "直接访问", "sessions": 1}
assert d["top_pages"][0] == {"path": "/pricing", "pv": 6}
def test_overview_trend_uses_dailystat_history(client, member_user):
_auth(client, member_user)
site_a = Site.objects.create(name="a", domains=["a.com"])
site_b = Site.objects.create(name="b", domains=["b.com"])
yn = NOW - timedelta(days=1) # 昨天同一本地时刻,必落上海昨日窗口
# a 站昨天 3 事件走真实 refresh_daily 落 DailyStat
va = Visitor.objects.create(site=site_a, visitor_id="va")
sa = _session(site_a, va, "sa", at=yn)
_events(site_a, va, sa, 3, "/", at=yn, prefix="ya")
refresh_daily(site_a, YESTERDAY)
# b 站昨天直接落一行(site=None 时验证 Sum 跨站点聚合)
vb = Visitor.objects.create(site=site_b, visitor_id="vb")
sb = _session(site_b, vb, "sb", at=yn)
_events(site_b, vb, sb, 4, "/", at=yn, prefix="yb")
refresh_daily(site_b, YESTERDAY)
d = client.get("/api/admin/dashboard/overview").json()["data"]
assert d["trend"][-2] == {"date": YESTERDAY.isoformat(), "pv": 7, "uv": 2,
"sessions": 2, "new_visitors": 0} # 3+4 跨站点求和
assert d["trend"][-1]["pv"] == 0 # 今天无事件,实时覆盖为 0
def test_overview_site_filter_and_404(client, member_user):
_auth(client, member_user)
site_a = Site.objects.create(name="a", domains=["a.com"])
site_b = Site.objects.create(name="b", domains=["b.com"])
va = _visitor(site_a, "va")
vb = _visitor(site_b, "vb")
sa = _session(site_a, va, "sa", referrer="google.com")
sb = _session(site_b, vb, "sb", referrer="")
_events(site_a, va, sa, 2, "/", prefix="a")
_events(site_b, vb, sb, 5, "/b", prefix="b")
# a 站昨天有 DailyStat 行——过滤 site_b 时不得混入
yn = NOW - timedelta(days=1)
_events(site_a, va, sa, 3, "/", at=yn, prefix="ay")
refresh_daily(site_a, YESTERDAY)
d = client.get("/api/admin/dashboard/overview").json()["data"]
assert d["today"]["pv"] == 7 # 缺省聚合全部站点
d = client.get("/api/admin/dashboard/overview", {"site": site_b.site_id}).json()["data"]
assert d["today"]["pv"] == 5 and d["today"]["uv"] == 1
assert d["today"]["active_now"] == 1 # 两访客 last_seen 均为 now,但只计本站
assert d["sources"] == [{"source": "直接访问", "sessions": 1}]
assert d["top_pages"][0] == {"path": "/b", "pv": 5}
assert d["trend"][-2]["pv"] == 0 # a 站昨日行被站点过滤排除
assert d["trend"][-1]["pv"] == 5 # 今天位仍是本站实时值
r = client.get("/api/admin/dashboard/overview", {"site": "site_nope"})
assert r.status_code == 404
body = r.json()
assert body["code"] == 404 and body["data"] is None and body["message"] == "site_not_found"