diff --git a/server/tracker/models.py b/server/tracker/models.py index 7e39f2a..eb14645 100644 --- a/server/tracker/models.py +++ b/server/tracker/models.py @@ -68,7 +68,7 @@ class VisitSession(models.Model): landing_path = models.CharField(max_length=500, blank=True, default="") event_count = models.PositiveIntegerField(default=0) duration_ms = models.PositiveBigIntegerField(default=0) # 会话内事件时间跨度 - stay_ms = models.PositiveBigIntegerField(default=0) # 有效停留累计(page_stay active_ms 之和) + stay_ms = models.PositiveBigIntegerField(default=0) # 有效停留(v1 active_ms 加法 + v2 page_id 区间并集,落库后重算回写) resume_of = models.ForeignKey("self", null=True, blank=True, on_delete=models.SET_NULL, related_name="resumes") # schema v2:session_resume 续接的前一会话(被指向会话删除后置 NULL 保留链) diff --git a/server/tracker/tests/test_dashboard.py b/server/tracker/tests/test_dashboard.py index 07ca27c..df4ff5c 100644 --- a/server/tracker/tests/test_dashboard.py +++ b/server/tracker/tests/test_dashboard.py @@ -4,6 +4,8 @@ from django.contrib.auth import get_user_model from django.utils import timezone from rest_framework.test import APIClient from tracker.models import DailyStat, Event, Site, SourceClassification, VisitSession, Visitor +from tracker.services.ingest import ingest_batch +from tracker.tests.test_ingest import SITE_ID, _batch, _ev, _v2_batch, _v2_event pytestmark = pytest.mark.django_db User = get_user_model() @@ -263,3 +265,28 @@ def test_overview_site_filter_and_404(client, member_user): assert r.status_code == 404 body = r.json() assert body["code"] == 404 and body["data"] is None and body["message"] == "site_not_found" + + +def test_overview_avg_stay_mixed_v1_v2_session(client, member_user): + """v1+v2 混合会话回归(阶段A T4):avg_stay_ms 读 VisitSession.stay_ms 重算值。 + 经真实 ingest 落库——v1 page_stay active_ms=8000(加法)+ v2 同 page_id 相邻区间 + [0,15000][15000,30000](并集=30000,非加法 60000)→ 会话 stay_ms=38000; + 单会话 avg=38000/1。聚合点不直接 SUM(Event.properties.active_ms), + T3 重算式回写后读数侧自动正确——本用例钉死该语义不被回归。""" + _auth(client, member_user) + site = Site.objects.create(name="s", domains=["a.com"], site_id=SITE_ID) + ingest_batch(site, _batch( # v1 批:建会话 sm + v1 停留增量 + [_ev(1), _ev(2, "page_stay", properties={"active_ms": 8000, "path": "/p"})], + sid="sm")) + r = ingest_batch(site, _v2_batch([ # v2 批:同会话同 page_id 两段相邻区间 + _v2_event("page_stay", "p1", "sm", seq=1, + active_from=0, active_to=15000, flush_reason="periodic"), + _v2_event("page_stay", "p1", "sm", seq=2, + active_from=15000, active_to=30000, flush_reason="periodic")], + sid="sm")) + assert (r["accepted"], r["rejected"]) == (2, []) + assert VisitSession.objects.get(session_id="sm").stay_ms == 38000 # 写侧:v1 SUM + v2 并集 + + 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 diff --git a/server/tracker/tests/test_visitors.py b/server/tracker/tests/test_visitors.py index aeb9268..372a0be 100644 --- a/server/tracker/tests/test_visitors.py +++ b/server/tracker/tests/test_visitors.py @@ -4,6 +4,8 @@ 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, SourceClassification, VisitSession, Visitor +from tracker.services.ingest import ingest_batch +from tracker.tests.test_ingest import SITE_ID, _batch, _ev, _v2_batch, _v2_event pytestmark = pytest.mark.django_db User = get_user_model() @@ -474,3 +476,43 @@ def test_device_model_and_env_extra_fields(client, member_user): assert d["env_extra"]["network_rtt"] == "50" d = client.get("/api/admin/visitors/v-old").json()["data"] assert d["device_model"] == "" and d["env_extra"] == {} + + +def test_mixed_v1_v2_stay_totals_and_detail(client, member_user): + """v1+v2 混合会话回归(阶段A T4):total_stay_ms/会话 stay_ms 均读 VisitSession.stay_ms + 重算值(v1 active_ms=8000 加法 + v2 同 page_id 区间 [0,15000][15000,30000] 并集 30000 + = 38000);详情/单会话/时间线对多条 page_stay 事件正常返回不崩——v2 区间事件 + properties 无 active_ms,时间线合并展示为 0 累加(展示层不炸、不虚增)。""" + _auth(client, member_user) + site = Site.objects.create(name="s", domains=["a.com"], site_id=SITE_ID) + ingest_batch(site, _batch( + [_ev(1), _ev(2, "page_stay", properties={"active_ms": 8000, "path": "/p"})], sid="sm")) + ingest_batch(site, _v2_batch([ + _v2_event("page_stay", "p1", "sm", seq=1, + active_from=0, active_to=15000, flush_reason="periodic"), + _v2_event("page_stay", "p1", "sm", seq=2, + active_from=15000, active_to=30000, flush_reason="periodic")], + sid="sm")) + sess = VisitSession.objects.get(session_id="sm") + assert sess.stay_ms == 38000 and sess.event_count == 4 # 写侧:v1 SUM + v2 并集 + + d = client.get("/api/admin/visitors").json()["data"] # 列表:Sum(sessions__stay_ms) + assert d["total"] == 1 + assert d["list"][0]["total_stay_ms"] == 38000 and d["list"][0]["session_count"] == 1 + + d = client.get("/api/admin/visitors/v1").json()["data"] # 详情:行字段 + 会话摘要 + assert d["total_stay_ms"] == 38000 + assert [s["stay_ms"] for s in d["sessions"]] == [38000] + + d = client.get("/api/admin/visitors/v1/sessions/sm").json()["data"] # 单会话:多条 page_stay 正常 + assert d["stay_ms"] == 38000 and d["event_count"] == 4 + # 4 条事件(1 page_view + 3 page_stay)合并成一条:v1 的 8000 并入 page_view, + # v2 两段区间事件无 active_ms → 展示层累加 0(真实停留看 stay_ms=38000) + assert [(e["event_name"], e["path"], e["active_ms"]) for e in d["events"]] == \ + [("page_view", "/p", 8000)] + + r = client.get("/api/admin/visitors/v1/timeline") # 时间线:同款合并不崩 + assert r.status_code == 200 + evs = r.json()["data"]["days"][0]["sessions"][0]["events"] + assert [(e["event_name"], e["path"], e["active_ms"]) for e in evs] == \ + [("page_view", "/p", 8000)]