Files
fangke/server/tracker/api/dashboard.py
T
2026-09-17 14:17:08 +08:00

178 lines
9.1 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""数据总览接口(客迹视觉 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]