feat: 访客列表/详情/时间线/会话接口

Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
浅念
2026-09-15 15:20:06 +08:00
parent 0b93814261
commit 9a2cd76e99
3 changed files with 491 additions and 0 deletions
+7
View File
@@ -3,6 +3,8 @@ from tracker.api.collect import CollectBatchView
from tracker.api.auth import LoginView, RefreshView, MeView, UserCreateView from tracker.api.auth import LoginView, RefreshView, MeView, UserCreateView
from tracker.api.sites import SiteListView, SiteDetailView from tracker.api.sites import SiteListView, SiteDetailView
from tracker.api.dashboard import OverviewView from tracker.api.dashboard import OverviewView
from tracker.api.visitors import (VisitorDetailView, VisitorListView,
VisitorSessionView, VisitorTimelineView)
urlpatterns = [ urlpatterns = [
path("api/collect/batch", CollectBatchView.as_view()), path("api/collect/batch", CollectBatchView.as_view()),
@@ -16,4 +18,9 @@ urlpatterns += [
path("api/admin/sites", SiteListView.as_view()), path("api/admin/sites", SiteListView.as_view()),
path("api/admin/sites/<int:pk>", SiteDetailView.as_view()), path("api/admin/sites/<int:pk>", SiteDetailView.as_view()),
path("api/admin/dashboard/overview", OverviewView.as_view()), path("api/admin/dashboard/overview", OverviewView.as_view()),
path("api/admin/visitors", VisitorListView.as_view()),
path("api/admin/visitors/<str:visitor_id>", VisitorDetailView.as_view()),
path("api/admin/visitors/<str:visitor_id>/timeline", VisitorTimelineView.as_view()),
path("api/admin/visitors/<str:visitor_id>/sessions/<str:session_id>",
VisitorSessionView.as_view()),
] ]
+229
View File
@@ -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)})
+255
View File
@@ -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"