diff --git a/server/tracker/api/dashboard.py b/server/tracker/api/dashboard.py index 5b0a49f..db436cf 100644 --- a/server/tracker/api/dashboard.py +++ b/server/tracker/api/dashboard.py @@ -12,7 +12,7 @@ """ from datetime import datetime, time, timedelta -from django.db.models import Count, Sum +from django.db.models import Count, Q, Sum from django.db.models.functions import Trunc from django.utils import timezone from rest_framework.response import Response @@ -128,9 +128,11 @@ class OverviewView(EnvelopeMixin, APIView): return rows.setdefault( (sid, day), {"pv": 0, "uv": 0, "sessions": 0, "new_visitors": 0}) + # pv 只计 page_view(与 daily_counts 同口径):page_stay 增量/sdk_loaded 不并计 for r in (ev.annotate(day=Trunc("occurred_at", "day", tzinfo=tz)) .values("site", "day") - .annotate(pv=Count("id"), uv=Count("visitor", distinct=True))): + .annotate(pv=Count("id", filter=Q(event_name="page_view")), + uv=Count("visitor", distinct=True))): row = _row(r["site"], r["day"].date()) row["pv"], row["uv"] = r["pv"], r["uv"] for r in (vs.annotate(day=Trunc("started_at", "day", tzinfo=tz)) diff --git a/server/tracker/services/stats.py b/server/tracker/services/stats.py index 3c791ba..eae8da1 100644 --- a/server/tracker/services/stats.py +++ b/server/tracker/services/stats.py @@ -7,7 +7,9 @@ def daily_counts(site, date) -> dict: """四指标共享口径(site=None 聚合全部站点)。所有指标排除爬虫。 日界取 TIME_ZONE(Asia/Shanghai)自然日:make_aware(datetime.combine(date, time.min))。 - 口径:pv/uv 均从事件表统计(排除 is_bot),uv=distinct visitor,与 pv 同源; + 口径:pv/uv 均从事件表统计(排除 is_bot):pv 只计 event_name=page_view + (SDK 0.2.0 每 15s 一条的 page_stay 增量与 sdk_loaded 不并计,防 PV 随停留时长灌倍), + uv=distinct visitor(任意事件即计); sessions 按会话 started_at 落入当日且访客非爬虫;new_visitors 按 Visitor.first_seen_at 落入当日。 refresh_daily 落库与 dashboard 今日实时位共用此函数,防两处口径漂移(Task8 审查结论)。 """ @@ -23,7 +25,7 @@ def daily_counts(site, date) -> dict: ev = ev.filter(site=site) vs = vs.filter(site=site) vis = vis.filter(site=site) - return {"pv": ev.count(), + return {"pv": ev.filter(event_name="page_view").count(), "uv": ev.values("visitor").distinct().count(), "sessions": vs.count(), "new_visitors": vis.count()} diff --git a/server/tracker/tests/test_dashboard.py b/server/tracker/tests/test_dashboard.py index df4ff5c..d3d798e 100644 --- a/server/tracker/tests/test_dashboard.py +++ b/server/tracker/tests/test_dashboard.py @@ -290,3 +290,24 @@ def test_overview_avg_stay_mixed_v1_v2_session(client, member_user): d = client.get("/api/admin/dashboard/overview", {"range": "today"}).json()["data"] assert d["cards"]["visits"]["value"] == 1 assert d["cards"]["avg_stay_ms"] == {"value": 38000, "delta_pct": None} # 38000/1;上期无会话 → None + + +def test_overview_trend_pv_only_page_view(client, member_user): + """PV 口径回归(阶段A T11):SDK 0.2.0 每 15s 一条 page_stay 增量 + sdk_loaded, + 趋势自愈回写 DailyStat.pv 只计 page_view(与 daily_counts 同口径)—— + 增量停留事件不得灌倍 PV;uv 仍按任意事件 distinct visitor 计。""" + _auth(client, member_user) + site = Site.objects.create(name="s", domains=["a.com"]) + v1 = _visitor(site, "v1", last_seen=timezone.now()) + s1 = _session(site, v1, "s1") + # 模拟一次活跃访问的事件流:1 次浏览 + SDK 装载 + 3 条 15s 停留增量 + names = ["page_view", "sdk_loaded", "page_stay", "page_stay", "page_stay"] + for i, name in enumerate(names): + Event.objects.create(site=site, visitor=v1, session=s1, + event_id=f"pvx-{i}", event_name=name, + occurred_at=NOW + timedelta(seconds=i), path="/") + + d = client.get("/api/admin/dashboard/overview").json()["data"] + assert d["trend"][-1] == {"date": TODAY.isoformat(), "uv": 1, "sessions": 1} + row = DailyStat.objects.get(site=site, date=TODAY) + assert (row.pv, row.uv) == (1, 1) # 5 条事件仅 1 条 page_view 计 PV diff --git a/server/tracker/tests/test_stats.py b/server/tracker/tests/test_stats.py index 8e3f6a2..b39c0cf 100644 --- a/server/tracker/tests/test_stats.py +++ b/server/tracker/tests/test_stats.py @@ -58,3 +58,18 @@ def test_refresh_daily_excludes_bots(db): assert (d.pv, d.uv, d.sessions) == (1, 1, 1) # 只计 human 的事件/会话 t = refresh_daily(site, timezone.localdate()) # 两位访客 first_seen_at 均为运行时,刷今天窗口才能非空验证 new_visitors assert t.new_visitors == 1 # 爬虫访客不计入新增 + +def test_refresh_daily_pv_only_counts_page_view(db): + # PV 口径回归(阶段A T11):SDK 0.2.0 每 15s 一条 page_stay 增量 + 启动 sdk_loaded, + # pv 只计 page_view——增量停留事件不得灌倍 PV(5 分钟活跃访问 ≈ 22 条事件只有 1 条是浏览)。 + # 固定时间戳钉死日界不依赖运行时刻(同 boundary 用例做法) + site = Site.objects.create(name="s", domains=["a.com"]) + v = Visitor.objects.create(site=site, visitor_id="v1") + sess = VisitSession.objects.create(site=site, visitor=v, session_id="s1", + started_at="2026-09-15T10:00:00Z", ended_at="2026-09-15T10:05:00Z") + names = ["page_view", "sdk_loaded", "page_view", "page_stay", "page_stay", "page_stay"] + for i, name in enumerate(names): + Event.objects.create(site=site, visitor=v, session=sess, event_id=f"x{i}", + event_name=name, occurred_at=f"2026-09-15T10:00:0{i}Z", path="/") + d = refresh_daily(site, date(2026, 9, 15)) + assert (d.pv, d.uv, d.sessions) == (2, 1, 1) # 6 条事件仅 2 条 page_view 计 PV;uv 仍按任意事件 distinct