0b93814261
- sources/top_pages Top8 加平局次序键(referrer_domain/path), 平局时跨请求顺序稳定 - test_dashboard 修正过时注释(b 站数据同为 refresh_daily 真实落行) pytest 79 passed 无回归。 Co-Authored-By: Claude Code <noreply@anthropic.com>
88 lines
3.9 KiB
Python
88 lines
3.9 KiB
Python
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", "referrer_domain")[: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]] # 平局时次序跨请求稳定
|