Files
fangke/server/tracker/api/dashboard.py
T
浅念 59de51c062 feat: 来源归因规则引擎(渠道/AI/搜索词/诚实状态/历史重算)
- constants 规则库 v1:AI/引擎/社交域名表、搜索词参数白名单、付费点击参数、
  应用内 UA 标记、CHANNELS 枚举;page_view 白名单开 nav_type/prerendering
- services/attribution.classify 纯函数:付费>AI>引擎>社交>referral 分层,
  UTM 证据叠加(combined/conflict/label_only),应用内 UA 叠加/兜底,
  引擎 TLD 国家 variant,搜索词白名单提取(Google 恒空不虚构)
- SourceClassification 派生表(迁移 0004):first_touch/session_entry 双 scope,
  rule_version 可追溯
- ingest 增量 best-effort:首访客/新会话时分类落库(savepoint+静默,
  失败不影响采集);validation 对 page_view referrer 完整 URL 参数清洗+截500
- recompute_attribution 命令:全量/按站幂等重算
- API:访客列表归因五字段+channel 筛选、详情 attribution 块(含最近5会话入口)、
  dashboard channels 聚合(sources 原口径保留);TS VisitorListParams 加 channel
- 测试 97 → 124(引擎20 + 集成/接口7)

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-16 17:03:15 +08:00

105 lines
4.9 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.
from datetime import datetime, time, timedelta
from django.db.models import Count, Sum
from django.utils import timezone
from rest_framework.views import APIView
from rest_framework.response import Response
from tracker.api.envelope import EnvelopeMixin, ok
from tracker.models import DailyStat, Event, Site, SourceClassification, VisitSession, Visitor
from tracker.services.stats import daily_counts
METRICS = ("pv", "uv", "sessions", "new_visitors")
def _day_bounds(date):
"""上海自然日窗口,与 daily_counts 同款 make_aware。"""
start = timezone.make_aware(datetime.combine(date, time.min))
return start, start + timedelta(days=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)
today = timezone.localdate()
counts = daily_counts(site, today) # 与 refresh_daily 共享口径(Task8 审查结论)
counts["active_now"] = self._active_now(site)
return ok({"today": counts,
"trend": self._trend(site, today, counts),
"sources": self._today_sources(site, today),
"channels": self._today_channels(site, today),
"top_pages": self._today_pages(site, today)})
@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, today_counts):
"""DailyStat 近 30 天(缺日补零;今天一位用 daily_counts 实时值覆盖——聚合作业未跑也不缺当日)。"""
days = 30
start = today - timedelta(days=days - 1)
qs = DailyStat.objects.filter(date__gte=start, date__lte=today)
if site is not None:
qs = qs.filter(site=site)
rows = {r["date"]: r for r in qs.values("date").annotate(
pv=Sum("pv"), uv=Sum("uv"), sessions=Sum("sessions"),
new_visitors=Sum("new_visitors"))}
out = []
for i in range(days):
date = start + timedelta(days=i)
if date == today:
out.append({"date": date.isoformat(),
**{k: today_counts[k] for k in METRICS}})
else:
r = rows.get(date, {})
out.append({"date": date.isoformat(),
**{k: r.get(k, 0) for k in METRICS}})
return out
def _today_sources(self, site, today):
start, end = _day_bounds(today)
q = VisitSession.objects.filter(visitor__is_bot=False,
started_at__gte=start, started_at__lt=end)
if site is not None:
q = q.filter(site=site)
return [{"source": r["referrer_domain"] or "直接访问", "sessions": r["n"]}
for r in q.values("referrer_domain")
.annotate(n=Count("id"))
.order_by("-n", "referrer_domain")[:8]] # 平局时次序跨请求稳定
def _today_channels(self, site, today):
"""当天会话按来源渠道聚合(session_entry 分类;sources 的升级版,原始口径保留)。
从分类表正向聚合 + Count(distinct session):无唯一约束下分类表偶发同键重复行
也不会把同一会话计两次。无分类行的会话(未重算的历史数据)不计入。"""
start, end = _day_bounds(today)
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)
return [{"channel": r["channel"] or "unknown", "sessions": r["n"]}
for r in q.values("channel")
.annotate(n=Count("session", distinct=True))
.order_by("-n", "channel")[:8]] # 平局时次序跨请求稳定
def _today_pages(self, site, today):
start, end = _day_bounds(today)
q = Event.objects.filter(is_bot=False, event_name="page_view",
occurred_at__gte=start, occurred_at__lt=end)
if site is not None:
q = q.filter(site=site)
return [{"path": r["path"], "pv": r["n"]}
for r in q.values("path")
.annotate(n=Count("id"))
.order_by("-n", "path")[:8]] # 平局时次序跨请求稳定