diff --git a/server/config/urls.py b/server/config/urls.py index 8d4dfb1..626f256 100644 --- a/server/config/urls.py +++ b/server/config/urls.py @@ -3,6 +3,8 @@ 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 +from tracker.api.visitors import (VisitorDetailView, VisitorListView, + VisitorSessionView, VisitorTimelineView) urlpatterns = [ path("api/collect/batch", CollectBatchView.as_view()), @@ -16,4 +18,9 @@ urlpatterns += [ path("api/admin/sites", SiteListView.as_view()), path("api/admin/sites/", SiteDetailView.as_view()), path("api/admin/dashboard/overview", OverviewView.as_view()), + path("api/admin/visitors", VisitorListView.as_view()), + path("api/admin/visitors/", VisitorDetailView.as_view()), + path("api/admin/visitors//timeline", VisitorTimelineView.as_view()), + path("api/admin/visitors//sessions/", + VisitorSessionView.as_view()), ] diff --git a/server/tracker/api/visitors.py b/server/tracker/api/visitors.py new file mode 100644 index 0000000..05f7c72 --- /dev/null +++ b/server/tracker/api/visitors.py @@ -0,0 +1,229 @@ +"""访客列表/详情/跨天时间线/单会话接口(管理台)。 + +M1 单组织多站点:访客 API 不做 site 过滤参数(后台看全站访客流,站点维度在 Dashboard)。 +访客列表查询按 (site,-last_seen_at) 索引逐站扫描或全表 order_by——M1 规模(万级访客)可接受, +量级上来后按站点分片或引入 site 过滤即可(索引已就位)。 +""" +from datetime import datetime, time, timedelta + +from django.db.models import Count, OuterRef, Q, Subquery, Sum +from django.utils import timezone +from rest_framework.response import Response +from rest_framework.views import APIView + +from tracker.api.envelope import EnvelopeMixin, ok +from tracker.models import Event, VisitSession, Visitor + +PAGE_SIZE = 20 +TIMELINE_DEFAULT_DAYS = 30 +TIMELINE_MAX_DAYS = 90 +TIMELINE_MAX_EVENTS = 1000 +ONLINE_WINDOW = timedelta(minutes=5) # 与 dashboard active_now 同口径 + + +def _row(visitor, last_session, online_cutoff): + """列表/详情共用行字段。last_source 依赖最近会话(由调用方批量取好传入,防 N+1)。""" + return { + "visitor_id": visitor.visitor_id, + "country": visitor.country, "province": visitor.province, "city": visitor.city, + "device_type": visitor.device_type, "os": visitor.os, + "browser": visitor.browser, "language": visitor.language, + "first_seen_at": visitor.first_seen_at.isoformat(), + "last_seen_at": visitor.last_seen_at.isoformat(), + "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), + "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): + 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} + + +def _annotate(qs): + """会话数/有效停留/最近会话标记一次注解到位。 + + last_source 不逐行 sessions.order_by("-started_at").first()(页大小 20 即 N+1): + 先用相关子查询把每个访客最近会话的 pk 标到行上(走 (visitor,-started_at) 索引), + 分页后一次 IN 查询取回整页会话——全程只多 2 条 SQL。 + """ + latest = (VisitSession.objects.filter(visitor=OuterRef("pk")) + .order_by("-started_at", "-pk")) # -pk 平局次序跨请求稳定 + return qs.annotate( + session_count=Count("sessions"), + total_stay_ms=Sum("sessions__stay_ms"), # 有效停留口径(非 duration_ms) + latest_session_pk=Subquery(latest.values("pk")[:1]), + ) + + +def _page_visitors(qs, request): + """过滤 + 分页 + 批量取最近会话,返回 (rows, total, page)。""" + p = request.query_params + if p.get("show_bot") != "1": # 默认排除爬虫;show_bot=1 才包含 + qs = qs.filter(is_bot=False) + if p.get("country"): + qs = qs.filter(country=p["country"]) + if p.get("device_type"): + qs = qs.filter(device_type=p["device_type"]) + if p.get("online") == "1": + qs = qs.filter(last_seen_at__gt=timezone.now() - ONLINE_WINDOW) + if p.get("is_new") == "1": # first_seen_at 落在今天(USE_TZ 下 __date 按当前时区取日期) + qs = qs.filter(first_seen_at__date=timezone.localdate()) + if p.get("search"): + q = p["search"] + qs = qs.filter(Q(visitor_id__istartswith=q) | Q(last_ip__istartswith=q)) + date_from = _parse_date(p.get("date_from")) + date_to = _parse_date(p.get("date_to")) + if date_from: + qs = qs.filter(last_seen_at__date__gte=date_from) + if date_to: + qs = qs.filter(last_seen_at__date__lte=date_to) + + total = qs.count() # 注解前 count:不必为计数做会话表 join + try: + page = int(p.get("page", 1)) + except (TypeError, ValueError): + page = 1 + page = max(1, page) + + page_qs = _annotate(qs).order_by("-last_seen_at", "-pk") + visitors = list(page_qs[(page - 1) * PAGE_SIZE: page * PAGE_SIZE]) + latest = {s.pk: s for s in VisitSession.objects.filter( + pk__in=[v.latest_session_pk for v in visitors if v.latest_session_pk])} + cutoff = timezone.now() - ONLINE_WINDOW + rows = [_row(v, latest.get(v.latest_session_pk), cutoff) for v in visitors] + return rows, total, page + + +def _parse_date(raw): + """YYYY-MM-DD 宽松解析:非法/缺失返回 None(该筛选不生效,不为此 400)。""" + try: + return datetime.strptime(raw, "%Y-%m-%d").date() + except (TypeError, ValueError): + return None + + +def _get_visitor(visitor_id): + """visitor_id 仅 (site,visitor_id) 唯一——跨站重名时取最近活跃者(M1 单组织,SDK id 撞名概率可忽略)。""" + return (Visitor.objects.filter(visitor_id=visitor_id) + .order_by("-last_seen_at", "-pk").first()) + + +def _not_found(message): + return Response({"code": 404, "data": None, "message": message}, status=404) + + +def _merge_page_stay(events): + """page_view/page_stay 服务端合并(时间线/单会话共用)。 + + 按 occurred_at 正序迭代:page_view 追加条目(active_ms=0);page_stay 向前找同 session 内 + 最近一条同 path 的 page_view 条目,把 active_ms 累加进去(同页多段隐藏/恢复多次累加); + 找不到匹配(含 page_stay 早于任何 page_view 出现)则独立成条目(event_name 保持 page_stay)。 + 作用域天然限定在本组事件序列(单会话/时间线单会话桶)——跨会话绝不合并。 + 只出 time/event_name/path/title/active_ms 白名单字段,不透出 properties 原文(隐私)。 + """ + items = [] + for e in events: + if e.event_name == "page_stay": + ms = int((e.properties or {}).get("active_ms") or 0) + for it in reversed(items): # 逆序 = "最近一条" + if it["event_name"] == "page_view" and it["path"] == e.path: + it["active_ms"] += ms + break + else: + items.append({"time": e.occurred_at.isoformat(), "event_name": e.event_name, + "path": e.path, "title": e.title, "active_ms": ms}) + else: + # page_view 初始 active_ms=0;其余(sdk_loaded/自定义事件)无停留概念 → null + active = 0 if e.event_name == "page_view" else None + items.append({"time": e.occurred_at.isoformat(), "event_name": e.event_name, + "path": e.path, "title": e.title, "active_ms": active}) + return items + + +class VisitorListView(EnvelopeMixin, APIView): + def get(self, request): + rows, total, page = _page_visitors(Visitor.objects.all(), request) + return ok({"list": rows, "total": total, "page": page}) + + +class VisitorDetailView(EnvelopeMixin, APIView): + def get(self, request, visitor_id): + visitor = _annotate(Visitor.objects.filter(visitor_id=visitor_id)) \ + .order_by("-last_seen_at", "-pk").first() + if not visitor: + return _not_found("visitor_not_found") + last_session = (VisitSession.objects.filter(pk=visitor.latest_session_pk).first() + if visitor.latest_session_pk else None) + # 全量会话摘要(visitor,-started_at 索引);M1 访客会话量有限不分页 + sessions = VisitSession.objects.filter(visitor=visitor).order_by("-started_at", "-pk") + cutoff = timezone.now() - ONLINE_WINDOW + return ok({**_row(visitor, last_session, cutoff), + "screen": visitor.screen, "timezone_name": visitor.timezone_name, + "last_ip": visitor.last_ip, "first_utm": visitor.first_utm or {}, + "first_landing_path": visitor.first_landing_path, + "first_referrer_domain": visitor.first_referrer_domain, + "sessions": [_session_summary(s) for s in sessions]}) + + +class VisitorTimelineView(EnvelopeMixin, APIView): + def get(self, request, visitor_id): + visitor = _get_visitor(visitor_id) + if not visitor: + return _not_found("visitor_not_found") + try: + days = int(request.query_params.get("days", TIMELINE_DEFAULT_DAYS)) + except (TypeError, ValueError): + days = TIMELINE_DEFAULT_DAYS + days = max(1, min(days, TIMELINE_MAX_DAYS)) # 1-90 夹紧,非法值回落默认 + + today = timezone.localdate() + start = timezone.make_aware( + datetime.combine(today - timedelta(days=days - 1), time.min)) # 上海自然日窗口 + # 倒序取满上限再翻正:截断保最新(更早的事件连同其独属会话不出现——若某会话事件被整段 + # 截掉,该会话不出组;部分截掉的 page_view 缺失时其 page_stay 按规则独立成条)。 + events = list(Event.objects.filter(visitor=visitor, occurred_at__gte=start) + .select_related("session") # 会话信息随行带出,免逐事件查询 + .order_by("-occurred_at", "-pk")[:TIMELINE_MAX_EVENTS]) + events.reverse() + + groups = {} # date -> {session.pk/None: {"session":..., "events":[...]}},插入序即正序 + for e in events: + day = groups.setdefault(timezone.localtime(e.occurred_at).date(), {}) + key = e.session.pk if e.session else None # session 为 NULL 的孤立事件并成一组 + 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)]) # 日期组倒序,日内会话/事件正序 + + +class VisitorSessionView(EnvelopeMixin, APIView): + def get(self, request, visitor_id, session_id): + visitor = _get_visitor(visitor_id) + if not visitor: + return _not_found("visitor_not_found") + session = VisitSession.objects.filter(visitor=visitor, session_id=session_id).first() + if not session: + return _not_found("session_not_found") + events = Event.objects.filter(session=session).order_by("occurred_at", "pk") # (session,occurred_at) 索引 + return ok({**_session_summary(session), "events": _merge_page_stay(events)}) diff --git a/server/tracker/tests/test_visitors.py b/server/tracker/tests/test_visitors.py new file mode 100644 index 0000000..b57daf3 --- /dev/null +++ b/server/tracker/tests/test_visitors.py @@ -0,0 +1,255 @@ +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 + +pytestmark = pytest.mark.django_db +User = get_user_model() + +# 用上海本地时刻做基准:时间线/日期筛选按本地自然日分组(localdate/localtime), +# 直接用 UTC 的 now 在本地 00:00-08:00 之间跑测试会错一天(与 test_dashboard 同款防 flaky) +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, **kw): + v = Visitor.objects.create(site=site, visitor_id=vid, **kw) + 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, at=NOW, referrer_domain="", utm=None, + landing_path="", event_count=0, duration_ms=0, stay_ms=0): + return VisitSession.objects.create(site=site, visitor=visitor, session_id=sid, + started_at=at, ended_at=at + timedelta(minutes=5), referrer_domain=referrer_domain, + utm=utm or {}, landing_path=landing_path, event_count=event_count, + duration_ms=duration_ms, stay_ms=stay_ms) + + +def _event(site, visitor, session, eid, name, at, path="", title="", active_ms=None): + return Event.objects.create(site=site, visitor=visitor, session=session, + event_id=eid, event_name=name, occurred_at=at, path=path, title=title, + properties={"active_ms": active_ms} if active_ms is not None else {}) + + +def test_list_fields_and_default_bot_excluded(client, member_user): + _auth(client, member_user) + site = Site.objects.create(name="s", domains=["a.com"]) + v1 = _visitor(site, "v1", country="中国", province="上海", city="上海", + device_type="desktop", os="Windows", browser="Chrome", language="zh-CN", + first_referrer_domain="google.com", visit_count=2, active_days=1, + last_seen=timezone.now()) + v2 = _visitor(site, "v2", first_utm={"utm_source": "baidu"}, + last_seen=timezone.now() - timedelta(hours=1)) + bot = _visitor(site, "bot-v") + Visitor.objects.filter(pk=bot.pk).update(is_bot=True) + _session(site, v1, "s1", at=NOW - timedelta(hours=2), stay_ms=120000) + _session(site, v1, "s2", at=NOW, referrer_domain="bing.com", stay_ms=30000) + _session(site, v2, "s3", stay_ms=5000) + _session(site, bot, "sb", stay_ms=999) + + r = client.get("/api/admin/visitors") + assert r.status_code == 200 + d = r.json()["data"] + assert d["total"] == 2 and d["page"] == 1 and len(d["list"]) == 2 + assert [x["visitor_id"] for x in d["list"]] == ["v1", "v2"] # last_seen 倒序 + row = d["list"][0] + assert set(row) == {"visitor_id", "country", "province", "city", "device_type", "os", + "browser", "language", "first_seen_at", "last_seen_at", "visit_count", + "active_days", "session_count", "total_stay_ms", "first_source", + "last_source", "online"} + assert row["country"] == "中国" and row["os"] == "Windows" and row["browser"] == "Chrome" + assert row["session_count"] == 2 + assert row["total_stay_ms"] == 150000 # 120000+30000,有效停留口径 + assert row["first_source"] == "google.com" + assert row["last_source"] == "bing.com" # 最近会话 s2 的 referrer + assert row["online"] is True + row2 = d["list"][1] + assert row2["first_source"] == "baidu" # first_referrer 空 → first_utm 兜底 + assert row2["last_source"] == "直接访问" # 会话无来源 → 兜底 + assert row2["total_stay_ms"] == 5000 and row2["online"] is False + + d = client.get("/api/admin/visitors", {"show_bot": "1"}).json()["data"] + assert d["total"] == 3 and len(d["list"]) == 3 + bot_row = next(x for x in d["list"] if x["visitor_id"] == "bot-v") + assert bot_row["first_source"] == "直接访问" # 无 referrer 无 utm + + +def test_list_filters_and_search(client, member_user): + _auth(client, member_user) + site = Site.objects.create(name="s", domains=["a.com"]) + _visitor(site, "cn-desktop", country="中国", device_type="desktop", + last_ip="1.2.3.4", last_seen=timezone.now()) + _visitor(site, "us-mobile", country="美国", device_type="mobile", + last_ip="5.6.7.8", last_seen=timezone.now() - timedelta(hours=2)) + old = _visitor(site, "old-visitor", last_seen=NOW - timedelta(days=1)) + Visitor.objects.filter(pk=old.pk).update(first_seen_at=NOW - timedelta(days=1)) + + ids = lambda d: [x["visitor_id"] for x in d["list"]] # noqa: E731 + + d = client.get("/api/admin/visitors", {"country": "中国"}).json()["data"] + assert ids(d) == ["cn-desktop"] + d = client.get("/api/admin/visitors", {"device_type": "mobile"}).json()["data"] + assert ids(d) == ["us-mobile"] + d = client.get("/api/admin/visitors", {"online": "1"}).json()["data"] + assert ids(d) == ["cn-desktop"] + d = client.get("/api/admin/visitors", {"search": "CN-"}).json()["data"] # istartswith 大小写不敏感 + assert ids(d) == ["cn-desktop"] + d = client.get("/api/admin/visitors", {"search": "5.6.7"}).json()["data"] # last_ip 前缀 + assert ids(d) == ["us-mobile"] + d = client.get("/api/admin/visitors", {"search": "zzz"}).json()["data"] + assert d["total"] == 0 and d["list"] == [] + d = client.get("/api/admin/visitors", {"is_new": "1"}).json()["data"] # first_seen 在今天 + assert set(ids(d)) == {"cn-desktop", "us-mobile"} + d = client.get("/api/admin/visitors", {"date_to": YESTERDAY.isoformat()}).json()["data"] + assert ids(d) == ["old-visitor"] # last_seen 落在昨天 + + +def test_list_pagination_and_order(client, member_user): + _auth(client, member_user) + site = Site.objects.create(name="s", domains=["a.com"]) + for i in range(25): + _visitor(site, f"v{i:02d}", last_seen=timezone.now() - timedelta(minutes=i)) + + d = client.get("/api/admin/visitors").json()["data"] + assert d["total"] == 25 and d["page"] == 1 and len(d["list"]) == 20 + assert d["list"][0]["visitor_id"] == "v00" and d["list"][-1]["visitor_id"] == "v19" + d = client.get("/api/admin/visitors", {"page": "2"}).json()["data"] + assert d["total"] == 25 and d["page"] == 2 and len(d["list"]) == 5 + assert [x["visitor_id"] for x in d["list"]] == ["v20", "v21", "v22", "v23", "v24"] + + +def test_detail_shape(client, member_user): + _auth(client, member_user) + site = Site.objects.create(name="s", domains=["a.com"]) + v = _visitor(site, "v1", screen="1920x1080", timezone_name="Asia/Shanghai", + last_ip="1.2.3.4", first_utm={"utm_source": "google"}, + first_referrer_domain="google.com", first_landing_path="/landing") + _session(site, v, "s-old", at=NOW - timedelta(hours=2), + referrer_domain="google.com", stay_ms=60000, duration_ms=300000, event_count=3) + _session(site, v, "s-new", at=NOW, utm={"utm_source": "wechat"}, + landing_path="/new", stay_ms=30000, duration_ms=120000, event_count=2) + + r = client.get("/api/admin/visitors/v1") + assert r.status_code == 200 + d = r.json()["data"] + assert d["screen"] == "1920x1080" and d["timezone_name"] == "Asia/Shanghai" + assert d["last_ip"] == "1.2.3.4" and d["first_utm"] == {"utm_source": "google"} + assert d["first_landing_path"] == "/landing" and d["first_referrer_domain"] == "google.com" + assert d["session_count"] == 2 and d["total_stay_ms"] == 90000 + assert d["first_source"] == "google.com" + assert d["last_source"] == "wechat" # 最近会话 s-new:referrer 空 → utm 兜底 + 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"} + assert srow["utm"] == {"utm_source": "wechat"} + assert srow["stay_ms"] == 30000 and srow["duration_ms"] == 120000 and srow["event_count"] == 2 + + +def test_timeline_day_grouped_and_stay_merge(client, member_user): + _auth(client, member_user) + site = Site.objects.create(name="s", domains=["a.com"]) + v = _visitor(site, "v1") + yn = NOW - timedelta(days=1) # 昨天同一本地时刻,必落上海昨日窗口 + sy = _session(site, v, "sy", at=yn) + _event(site, v, sy, "y1", "sdk_loaded", yn, path="/") + _event(site, v, sy, "y2", "page_view", yn + timedelta(seconds=1), path="/pricing", title="Pricing") + _event(site, v, sy, "y3", "page_stay", yn + timedelta(seconds=2), path="/pricing", active_ms=60000) + st = _session(site, v, "st", at=NOW) + _event(site, v, st, "t1", "sdk_loaded", NOW, path="/") + _event(site, v, st, "t2", "page_view", NOW + timedelta(seconds=1), path="/pricing", title="Pricing") + _event(site, v, st, "t3", "page_stay", NOW + timedelta(seconds=2), path="/pricing", active_ms=78000) + _event(site, v, st, "t4", "page_stay", NOW + timedelta(seconds=3), path="/pricing", active_ms=20000) + + r = client.get("/api/admin/visitors/v1/timeline") + assert r.status_code == 200 + groups = r.json()["data"] + assert [g["date"] for g in groups] == [TODAY.isoformat(), YESTERDAY.isoformat()] # 日期组倒序 + today_g, yest_g = groups + assert set(today_g) == {"date", "sessions"} + assert [s["session_id"] for s in today_g["sessions"]] == ["st"] + assert set(today_g["sessions"][0]) == {"session_id", "started_at", "events"} + evs = today_g["sessions"][0]["events"] + assert set(evs[0]) == {"time", "event_name", "path", "title", "active_ms"} # 白名单字段 + assert evs[0]["event_name"] == "sdk_loaded" and evs[0]["active_ms"] is None + pricing = evs[1] + assert pricing["event_name"] == "page_view" and pricing["path"] == "/pricing" + assert pricing["title"] == "Pricing" + assert pricing["active_ms"] == 98000 # 78000 + 20000 两段累加进同一条 page_view + y_evs = yest_g["sessions"][0]["events"] + assert len(y_evs) == 2 # sdk_loaded + 合并后的 /pricing + assert y_evs[1]["path"] == "/pricing" and y_evs[1]["active_ms"] == 60000 # 跨天不合并 + assert y_evs[1]["title"] == "Pricing" + + +def test_timeline_days_param_and_404(client, member_user): + _auth(client, member_user) + site = Site.objects.create(name="s", domains=["a.com"]) + v = _visitor(site, "v1") + sy = _session(site, v, "sy", at=NOW - timedelta(days=1)) + _event(site, v, sy, "y1", "page_view", NOW - timedelta(days=1), path="/") + st = _session(site, v, "st", at=NOW) + _event(site, v, st, "t1", "page_view", NOW, path="/") + + 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()] + r = client.get("/api/admin/visitors/v1/timeline", {"days": "1"}) + assert [g["date"] for g in r.json()["data"]] == [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") + assert r.status_code == 404 + body = r.json() + assert body["code"] == 404 and body["data"] is None and body["message"] == "visitor_not_found" + + +def test_session_detail_ordered(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", referrer_domain="d.com", landing_path="/start", + event_count=5, duration_ms=90000, stay_ms=45000) + base = NOW + _event(site, v, s, "e2", "page_view", base + timedelta(seconds=2), path="/a", title="A") # 乱序造数 + _event(site, v, s, "e0", "page_stay", base, path="/x", active_ms=15000) # 先于任何 page_view → 独立 + _event(site, v, s, "e4", "page_stay", base + timedelta(seconds=4), path="/a", active_ms=30000) # 并入 /a + _event(site, v, s, "e1", "sdk_loaded", base + timedelta(seconds=1), path="/") + _event(site, v, s, "e3", "page_stay", base + timedelta(seconds=3), path="/b", active_ms=7000) # 无 /b page_view → 独立 + + r = client.get("/api/admin/visitors/v1/sessions/s1") + 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"} + 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 + 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"] + assert [e["active_ms"] for e in evs] == [15000, None, 30000, 7000] # e4 并入 /a;/x /b 无匹配独立成条 + + r = client.get("/api/admin/visitors/v1/sessions/nope") + assert r.status_code == 404 and r.json()["message"] == "session_not_found"