From a0f7bdb87e664dfca853eb49214cccc890edd1bb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=B5=85=E5=BF=B5?= Date: Tue, 15 Sep 2026 14:53:01 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=80=BB=E8=A7=88=E8=81=9A=E5=90=88?= =?UTF-8?q?=E6=8E=A5=E5=8F=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit GET /api/admin/dashboard/overview?site=(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 --- server/config/urls.py | 2 + server/tracker/api/dashboard.py | 85 ++++++++++++++++ server/tracker/tests/test_dashboard.py | 135 +++++++++++++++++++++++++ 3 files changed, 222 insertions(+) create mode 100644 server/tracker/api/dashboard.py create mode 100644 server/tracker/tests/test_dashboard.py diff --git a/server/config/urls.py b/server/config/urls.py index 781f956..8d4dfb1 100644 --- a/server/config/urls.py +++ b/server/config/urls.py @@ -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/", SiteDetailView.as_view()), + path("api/admin/dashboard/overview", OverviewView.as_view()), ] diff --git a/server/tracker/api/dashboard.py b/server/tracker/api/dashboard.py new file mode 100644 index 0000000..c87a9dd --- /dev/null +++ b/server/tracker/api/dashboard.py @@ -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]] diff --git a/server/tracker/tests/test_dashboard.py b/server/tracker/tests/test_dashboard.py new file mode 100644 index 0000000..69758f8 --- /dev/null +++ b/server/tracker/tests/test_dashboard.py @@ -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"