35f7b55946
Co-Authored-By: Claude Code <noreply@anthropic.com>
178 lines
9.1 KiB
Python
178 lines
9.1 KiB
Python
"""数据总览接口(客迹视觉 V1:周期卡 + 趋势双线 + 渠道分布)。
|
||
|
||
?range=today|7d|30d(默认 7d,非法值回落默认)+ ?site=(可选,缺省聚合全部站点)。
|
||
- 周期卡四指标全口径排除爬虫,与上一个等长周期比较出 delta_pct(today 比昨天);
|
||
上期值为 0 时 delta 为 null(前端显示 —)。
|
||
- 趋势为日粒度:窗口内 Event/VisitSession 按上海自然日实时聚合(缺日补零,今天一位
|
||
天然实时),不依赖定时聚合作业;口径与 refresh_daily/daily_counts 一致,且对有数据的
|
||
日顺手自愈回写 DailyStat(best-effort)——DailyStat 保留给历史归档消费者。
|
||
- channels 按窗口内会话入口分类聚合,pct = 占已分类会话比(列表含 direct/unknown,
|
||
总和 ≈ 1;未重算出分类行的历史会话不计入)。
|
||
- 最近访问不在本接口做:前端调既有 /api/admin/visitors?page=1 取前 5 行。
|
||
"""
|
||
from datetime import datetime, time, timedelta
|
||
|
||
from django.db.models import Count, Sum
|
||
from django.db.models.functions import Trunc
|
||
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 DailyStat, Event, Site, SourceClassification, VisitSession, Visitor
|
||
|
||
RANGE_DAYS = {"today": 1, "7d": 7, "30d": 30}
|
||
CARD_KEYS = ("visitors", "visits", "avg_stay_ms", "returning")
|
||
|
||
|
||
def _window_stats(site, start, end):
|
||
"""窗口四指标(site=None 聚合全部站点;全口径排除爬虫)。
|
||
|
||
visitors 按 Event distinct visitor(与 daily_counts 的 uv 同源,跨日不重复计);
|
||
visits 按会话 started_at 落窗;avg_stay_ms = Sum(stay_ms)/会话数(0 保护);
|
||
returning = visitors - 窗口内新访客(first_seen_at 落窗;异常数据下限 0)。
|
||
"""
|
||
ev = Event.objects.filter(is_bot=False,
|
||
occurred_at__gte=start, occurred_at__lt=end)
|
||
vs = VisitSession.objects.filter(visitor__is_bot=False,
|
||
started_at__gte=start, started_at__lt=end)
|
||
vis = Visitor.objects.filter(is_bot=False,
|
||
first_seen_at__gte=start, first_seen_at__lt=end)
|
||
if site is not None:
|
||
ev = ev.filter(site=site)
|
||
vs = vs.filter(site=site)
|
||
vis = vis.filter(site=site)
|
||
agg = vs.aggregate(n=Count("id"), stay=Sum("stay_ms"))
|
||
sessions = agg["n"]
|
||
visitors = ev.values("visitor").distinct().count()
|
||
new_visitors = vis.count()
|
||
return {"visitors": visitors,
|
||
"visits": sessions,
|
||
"avg_stay_ms": round((agg["stay"] or 0) / sessions) if sessions else 0,
|
||
"returning": max(visitors - new_visitors, 0)}
|
||
|
||
|
||
def _delta(cur, prev):
|
||
"""环比百分比(保留 1 位小数);上期值为 0 无意义 → None。"""
|
||
if prev == 0:
|
||
return None
|
||
return round((cur - prev) / prev * 100, 1)
|
||
|
||
|
||
class OverviewView(EnvelopeMixin, APIView):
|
||
def get(self, request):
|
||
site = None
|
||
sid = request.query_params.get("site")
|
||
if sid:
|
||
site = Site.objects.filter(site_id=sid).first()
|
||
if not site:
|
||
return Response({"code": 404, "data": None,
|
||
"message": "site_not_found"}, status=404)
|
||
days = RANGE_DAYS.get(request.query_params.get("range", "7d"), 7)
|
||
today = timezone.localdate()
|
||
# 当前窗口与上期等长窗口(上海自然日对齐:[T-N+1, T+1) 与 [T-2N+1, T-N+1))
|
||
cur_start = timezone.make_aware(
|
||
datetime.combine(today - timedelta(days=days - 1), time.min))
|
||
cur_end = timezone.make_aware(
|
||
datetime.combine(today + timedelta(days=1), time.min))
|
||
prev_start = cur_start - timedelta(days=days)
|
||
|
||
cur = _window_stats(site, cur_start, cur_end)
|
||
prev = _window_stats(site, prev_start, cur_start)
|
||
online = self._active_now(site)
|
||
return ok({
|
||
"header": {"online": online, "active_now": online}, # active_now 兼容保留
|
||
"cards": {k: {"value": cur[k], "delta_pct": _delta(cur[k], prev[k])}
|
||
for k in CARD_KEYS},
|
||
"trend": self._trend(site, today, days),
|
||
"channels": self._channels(site, cur_start, cur_end),
|
||
})
|
||
|
||
@staticmethod
|
||
def _active_now(site):
|
||
# 5 分钟内有行为的访客(last_seen_at 有 site,-last_seen_at 索引;不用无索引的 received_at)
|
||
q = Visitor.objects.filter(is_bot=False,
|
||
last_seen_at__gt=timezone.now() - timedelta(minutes=5))
|
||
if site is not None:
|
||
q = q.filter(site=site)
|
||
return q.count()
|
||
|
||
def _trend(self, site, today, days):
|
||
"""窗口每日 [{date, uv, sessions}]:对窗口内 Event/VisitSession 按上海自然日
|
||
实时聚合(缺日补零,今天一位天然实时),不依赖定时聚合作业——DailyStat 为空
|
||
时历史位也是真实值。四指标口径与 daily_counts 一致;聚合出的按站按日数据顺手
|
||
自愈回写 DailyStat(best-effort,失败不影响响应;多站点按站拆行互不干扰),
|
||
DailyStat 保留给历史归档消费者。
|
||
性能:窗口 ≤30 天,走 (site,occurred_at)/(site,started_at) 索引的按日聚合,
|
||
当前量级毫秒级。"""
|
||
tz = timezone.get_current_timezone()
|
||
start = timezone.make_aware(
|
||
datetime.combine(today - timedelta(days=days - 1), time.min))
|
||
end = timezone.make_aware(
|
||
datetime.combine(today + timedelta(days=1), time.min))
|
||
ev = Event.objects.filter(is_bot=False,
|
||
occurred_at__gte=start, occurred_at__lt=end)
|
||
vs = VisitSession.objects.filter(visitor__is_bot=False,
|
||
started_at__gte=start, started_at__lt=end)
|
||
vis = Visitor.objects.filter(is_bot=False,
|
||
first_seen_at__gte=start, first_seen_at__lt=end)
|
||
if site is not None:
|
||
ev = ev.filter(site=site)
|
||
vs = vs.filter(site=site)
|
||
vis = vis.filter(site=site)
|
||
# 按站按日 Trunc 聚合(site=None 也拆站行:回写 DailyStat 需要站点维度;
|
||
# visitor/session 天然属单站,跨站求和 = 全局聚合值)
|
||
rows = {} # (site_id, date) -> {pv, uv, sessions, new_visitors},仅有数据的日
|
||
|
||
def _row(sid, day):
|
||
return rows.setdefault(
|
||
(sid, day), {"pv": 0, "uv": 0, "sessions": 0, "new_visitors": 0})
|
||
|
||
for r in (ev.annotate(day=Trunc("occurred_at", "day", tzinfo=tz))
|
||
.values("site", "day")
|
||
.annotate(pv=Count("id"), uv=Count("visitor", distinct=True))):
|
||
row = _row(r["site"], r["day"].date())
|
||
row["pv"], row["uv"] = r["pv"], r["uv"]
|
||
for r in (vs.annotate(day=Trunc("started_at", "day", tzinfo=tz))
|
||
.values("site", "day")
|
||
.annotate(n=Count("id"))):
|
||
_row(r["site"], r["day"].date())["sessions"] = r["n"]
|
||
for r in (vis.annotate(day=Trunc("first_seen_at", "day", tzinfo=tz))
|
||
.values("site", "day")
|
||
.annotate(n=Count("id"))):
|
||
_row(r["site"], r["day"].date())["new_visitors"] = r["n"]
|
||
# 自愈回写:仅窗口内有数据的日(空窗口不产生行),失败不影响趋势响应
|
||
for (sid, date), v in rows.items():
|
||
try:
|
||
DailyStat.objects.update_or_create(site_id=sid, date=date, defaults=v)
|
||
except Exception: # best-effort:回写失败不影响响应
|
||
pass
|
||
out = []
|
||
first = today - timedelta(days=days - 1)
|
||
for i in range(days):
|
||
date = first + timedelta(days=i)
|
||
day_rows = [v for (_, d), v in rows.items() if d == date]
|
||
out.append({"date": date.isoformat(),
|
||
"uv": sum(r["uv"] for r in day_rows),
|
||
"sessions": sum(r["sessions"] for r in day_rows)})
|
||
return out
|
||
|
||
def _channels(self, site, start, end):
|
||
"""窗口内会话按 session_entry 分类聚合(session.started_at 落窗)。
|
||
从分类表正向聚合 + Count(distinct session):无唯一约束下分类表偶发同键重复行
|
||
也不会把同一会话计两次。pct = 占已分类会话比(含 direct/unknown,总和 ≈ 1;
|
||
无分类行的会话——未重算的历史数据——不计入)。"""
|
||
q = SourceClassification.objects.filter(
|
||
scope="session_entry",
|
||
session__visitor__is_bot=False,
|
||
session__started_at__gte=start, session__started_at__lt=end)
|
||
if site is not None:
|
||
q = q.filter(site=site)
|
||
rows = list(q.values("channel")
|
||
.annotate(n=Count("session", distinct=True))
|
||
.order_by("-n", "channel")) # 平局时次序跨请求稳定
|
||
total = sum(r["n"] for r in rows)
|
||
return [{"channel": r["channel"] or "unknown", "sessions": r["n"],
|
||
"pct": round(r["n"] / total, 4) if total else 0.0}
|
||
for r in rows]
|