feat: 访客列表/详情/时间线/会话接口
Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
@@ -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)})
|
||||
Reference in New Issue
Block a user