diff --git a/server/tracker/api/visitors.py b/server/tracker/api/visitors.py index 05f7c72..da0ff21 100644 --- a/server/tracker/api/visitors.py +++ b/server/tracker/api/visitors.py @@ -21,6 +21,11 @@ TIMELINE_MAX_EVENTS = 1000 ONLINE_WINDOW = timedelta(minutes=5) # 与 dashboard active_now 同口径 +def _source(referrer_domain, utm): + """来源三级兜底:referrer_domain → utm.utm_source → 直接访问(访客档案/会话共用)。""" + return referrer_domain or (utm or {}).get("utm_source") or "直接访问" + + def _row(visitor, last_session, online_cutoff): """列表/详情共用行字段。last_source 依赖最近会话(由调用方批量取好传入,防 N+1)。""" return { @@ -33,25 +38,20 @@ def _row(visitor, last_session, online_cutoff): "visit_count": visitor.visit_count, "active_days": visitor.active_days, "session_count": visitor.session_count, "total_stay_ms": visitor.total_stay_ms or 0, # 无会话时 Sum 为 None → 0 - "first_source": visitor.first_referrer_domain - or (visitor.first_utm or {}).get("utm_source") or "直接访问", - "last_source": _session_source(last_session), + "first_source": _source(visitor.first_referrer_domain, visitor.first_utm), + "last_source": _source(last_session.referrer_domain, last_session.utm) + if last_session else "直接访问", # 无会话访客(仅采集异常场景) "online": visitor.last_seen_at > online_cutoff, # Python 侧比对(last_seen_at 已随行取出) } -def _session_source(session): - if not session: - return "直接访问" # 无会话访客(仅可能出现在采集异常场景) - return session.referrer_domain or (session.utm or {}).get("utm_source") or "直接访问" - - def _session_summary(s): + # duration_ms 仅内部字段不展示——前端时长一律用 stay_ms(有效停留口径) return {"session_id": s.session_id, "started_at": s.started_at.isoformat(), "ended_at": s.ended_at.isoformat(), "referrer_domain": s.referrer_domain, "utm": s.utm, "landing_path": s.landing_path, "event_count": s.event_count, - "duration_ms": s.duration_ms, "stay_ms": s.stay_ms} + "stay_ms": s.stay_ms} def _annotate(qs): @@ -199,6 +199,7 @@ class VisitorTimelineView(EnvelopeMixin, APIView): events = list(Event.objects.filter(visitor=visitor, occurred_at__gte=start) .select_related("session") # 会话信息随行带出,免逐事件查询 .order_by("-occurred_at", "-pk")[:TIMELINE_MAX_EVENTS]) + truncated = len(events) >= TIMELINE_MAX_EVENTS # 切片取满即窗口内可能还有更多 events.reverse() groups = {} # date -> {session.pk/None: {"session":..., "events":[...]}},插入序即正序 @@ -208,13 +209,14 @@ class VisitorTimelineView(EnvelopeMixin, APIView): bucket = day.setdefault(key, {"session": e.session, "events": []}) bucket["events"].append(e) - return ok([{"date": d.isoformat(), - "sessions": [{"session_id": b["session"].session_id if b["session"] else None, - "started_at": b["session"].started_at.isoformat() - if b["session"] else None, - "events": _merge_page_stay(b["events"])} - for b in groups[d].values()]} - for d in sorted(groups, reverse=True)]) # 日期组倒序,日内会话/事件正序 + return ok({"days": [{"date": d.isoformat(), + "sessions": [{"session_id": b["session"].session_id if b["session"] else None, + "started_at": b["session"].started_at.isoformat() + if b["session"] else None, + "events": _merge_page_stay(b["events"])} + for b in groups[d].values()]} + for d in sorted(groups, reverse=True)], # 日期组倒序,日内会话/事件正序 + "truncated": truncated}) # 前端区分"没动静"和"被截断" class VisitorSessionView(EnvelopeMixin, APIView): diff --git a/server/tracker/tests/test_visitors.py b/server/tracker/tests/test_visitors.py index b57daf3..d70d9cf 100644 --- a/server/tracker/tests/test_visitors.py +++ b/server/tracker/tests/test_visitors.py @@ -162,9 +162,9 @@ def test_detail_shape(client, member_user): assert [s["session_id"] for s in d["sessions"]] == ["s-new", "s-old"] # 最近在前 srow = d["sessions"][0] assert set(srow) == {"session_id", "started_at", "ended_at", "referrer_domain", "utm", - "landing_path", "event_count", "duration_ms", "stay_ms"} + "landing_path", "event_count", "stay_ms"} # duration_ms 内部字段不展示 assert srow["utm"] == {"utm_source": "wechat"} - assert srow["stay_ms"] == 30000 and srow["duration_ms"] == 120000 and srow["event_count"] == 2 + assert srow["stay_ms"] == 30000 and srow["event_count"] == 2 def test_timeline_day_grouped_and_stay_merge(client, member_user): @@ -184,7 +184,10 @@ def test_timeline_day_grouped_and_stay_merge(client, member_user): r = client.get("/api/admin/visitors/v1/timeline") assert r.status_code == 200 - groups = r.json()["data"] + data = r.json()["data"] + assert set(data) == {"days", "truncated"} + assert data["truncated"] is False # 未触 1000 上限 + groups = data["days"] assert [g["date"] for g in groups] == [TODAY.isoformat(), YESTERDAY.isoformat()] # 日期组倒序 today_g, yest_g = groups assert set(today_g) == {"date", "sessions"} @@ -214,9 +217,9 @@ def test_timeline_days_param_and_404(client, member_user): r = client.get("/api/admin/visitors/v1/timeline", {"days": "200"}) assert r.status_code == 200 # 超界夹紧到 90,不报错 - assert [g["date"] for g in r.json()["data"]] == [TODAY.isoformat(), YESTERDAY.isoformat()] + assert [g["date"] for g in r.json()["data"]["days"]] == [TODAY.isoformat(), YESTERDAY.isoformat()] r = client.get("/api/admin/visitors/v1/timeline", {"days": "1"}) - assert [g["date"] for g in r.json()["data"]] == [TODAY.isoformat()] # 只含今天窗口 + assert [g["date"] for g in r.json()["data"]["days"]] == [TODAY.isoformat()] # 只含今天窗口 r = client.get("/api/admin/visitors/v1/timeline", {"days": "abc"}) assert r.status_code == 200 # 非法 days → 默认 30 r = client.get("/api/admin/visitors/nope/timeline") @@ -242,10 +245,10 @@ def test_session_detail_ordered(client, member_user): assert r.status_code == 200 d = r.json()["data"] assert set(d) == {"session_id", "started_at", "ended_at", "referrer_domain", "utm", - "landing_path", "event_count", "duration_ms", "stay_ms", "events"} + "landing_path", "event_count", "stay_ms", "events"} # duration_ms 不展示 assert d["session_id"] == "s1" and d["referrer_domain"] == "d.com" assert d["landing_path"] == "/start" and d["event_count"] == 5 - assert d["duration_ms"] == 90000 and d["stay_ms"] == 45000 + assert d["stay_ms"] == 45000 evs = d["events"] assert [e["event_name"] for e in evs] == ["page_stay", "sdk_loaded", "page_view", "page_stay"] # occurred_at 正序 assert [e["path"] for e in evs] == ["/x", "/", "/a", "/b"] @@ -253,3 +256,37 @@ def test_session_detail_ordered(client, member_user): r = client.get("/api/admin/visitors/v1/sessions/nope") assert r.status_code == 404 and r.json()["message"] == "session_not_found" + + +def test_timeline_truncates_at_1000(client, member_user): + _auth(client, member_user) + site = Site.objects.create(name="s", domains=["a.com"]) + v = _visitor(site, "v1") + s = _session(site, v, "s1", at=NOW - timedelta(hours=3)) + base = NOW - timedelta(hours=3) # 跨不跨天皆可,按 path 集合断言不依赖分组形态 + Event.objects.bulk_create([Event(site=site, visitor=v, session=s, + event_id=f"e{i}", event_name="page_view", + occurred_at=base + timedelta(seconds=i), path=f"/p{i}") for i in range(1001)]) + + r = client.get("/api/admin/visitors/v1/timeline") + assert r.status_code == 200 + d = r.json()["data"] + assert d["truncated"] is True # 触上限(前端可区分"没动静"和"被截断") + paths = [e["path"] for g in d["days"] for sess in g["sessions"] for e in sess["events"]] + assert len(paths) == 1000 # 条数封顶 + assert "/p0" not in paths # 最旧一条被截掉 + assert "/p1000" in paths # 最新保留 + + +def test_timeline_orphan_events_without_session(client, member_user): + _auth(client, member_user) + site = Site.objects.create(name="s", domains=["a.com"]) + v = _visitor(site, "v1") + _event(site, v, None, "o1", "sdk_loaded", NOW, path="/") # session=NULL 孤立事件 + _event(site, v, None, "o2", "page_stay", NOW + timedelta(seconds=1), path="/x", active_ms=100) + + r = client.get("/api/admin/visitors/v1/timeline") + assert r.status_code == 200 # 不崩溃,归入 session_id=null 桶 + sess = r.json()["data"]["days"][0]["sessions"][0] + assert sess["session_id"] is None and sess["started_at"] is None + assert [e["event_name"] for e in sess["events"]] == ["sdk_loaded", "page_stay"]