fix: 访客API 微收口(duration_ms 移除/truncated 标记/锁定测试/source 收敛)
Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
@@ -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(),
|
||||
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)]) # 日期组倒序,日内会话/事件正序
|
||||
for d in sorted(groups, reverse=True)], # 日期组倒序,日内会话/事件正序
|
||||
"truncated": truncated}) # 前端区分"没动静"和"被截断"
|
||||
|
||||
|
||||
class VisitorSessionView(EnvelopeMixin, APIView):
|
||||
|
||||
@@ -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"]
|
||||
|
||||
Reference in New Issue
Block a user