From 8f20d8d9a296b8870a7d7ab1f89c0c6320012e4a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=B5=85=E5=BF=B5?= Date: Thu, 17 Sep 2026 12:18:37 +0800 Subject: [PATCH] =?UTF-8?q?feat(admin):=20=E6=95=B0=E6=8D=AE=E6=80=BB?= =?UTF-8?q?=E8=A7=88=E6=8C=89=E5=AE=A2=E8=BF=B9=E8=A7=86=E8=A7=89V1?= =?UTF-8?q?=E9=87=8D=E5=81=9A=EF=BC=88=E5=91=A8=E6=9C=9F=E5=8D=A1/?= =?UTF-8?q?=E8=B6=8B=E5=8A=BF=E5=8F=8C=E7=BA=BF/=E6=B8=A0=E9=81=93?= =?UTF-8?q?=E6=9D=A1=E5=BD=A2/=E6=9C=80=E8=BF=91=E8=AE=BF=E9=97=AE?= =?UTF-8?q?=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Code --- admin/src/api/tracker/dashboard.ts | 66 +- admin/src/api/tracker/visitors.ts | 2 + admin/src/utils/tracker/index.ts | 23 + admin/src/views/tracker/dashboard/index.vue | 785 ++++++++++++++++---- server/tracker/api/dashboard.py | 134 ++-- server/tracker/api/visitors.py | 11 +- server/tracker/tests/test_dashboard.py | 209 ++++-- server/tracker/tests/test_visitors.py | 25 +- 8 files changed, 955 insertions(+), 300 deletions(-) diff --git a/admin/src/api/tracker/dashboard.ts b/admin/src/api/tracker/dashboard.ts index 9e22907..fde8246 100644 --- a/admin/src/api/tracker/dashboard.ts +++ b/admin/src/api/tracker/dashboard.ts @@ -1,59 +1,59 @@ import request from '@/utils/http' /** - * 总览 API(server/tracker/api/dashboard.py) + * 总览 API(server/tracker/api/dashboard.py,客迹视觉 V1 形状) * - * 今日四指标与 refresh_daily 共享口径(daily_counts:排除爬虫,上海自然日); - * site 参数传 site_id,不传 = 聚合全部站点(站点不存在返回 404 site_not_found)。 + * 周期口径全排除爬虫;site 参数传 site_id,不传 = 聚合全部站点 + * (站点不存在返回 404 site_not_found);range 非法值后端回落 7d。 */ -/** 今日四指标 + 当前在线(5 分钟内有行为的非爬虫访客数) */ -export interface TodayCounts { - pv: number - uv: number - sessions: number - new_visitors: number +/** 周期枚举:today=上海自然日;7d/30d=近 N 天窗口 */ +export type OverviewRange = 'today' | '7d' | '30d' + +/** 页头在线信息(active_now 是 online 的兼容别名,二者恒相等) */ +export interface OverviewHeader { + online: number active_now: number } -/** 近 30 天趋势点(缺日补零,今天一位用实时值覆盖;每日含全部四指标) */ +/** 周期卡指标;delta_pct = 与上一个等长周期比较的百分比(today 比昨天), + * 上期值为 0 时为 null(前端显示 —) */ +export interface CardMetric { + value: number + delta_pct: number | null +} + +/** 四张周期卡:访客数 / 访问次数 / 平均有效停留(ms) / 回访访客 */ +export interface OverviewCards { + visitors: CardMetric + visits: CardMetric + avg_stay_ms: CardMetric + returning: CardMetric +} + +/** 趋势点(日粒度:历史位为 DailyStat 聚合、缺日补零,今天一位为实时值) */ export interface TrendPoint { date: string - pv: number uv: number sessions: number - new_visitors: number } -/** 今日来源 TOP8(referrer_domain 为空归并为"直接访问") */ -export interface SourceItem { - source: string - sessions: number -} - -/** 今日渠道构成 TOP8(session_entry 分类聚合,sessions 去重计数; - * channel 为 CHANNELS 枚举,无分类行不计入——未重算的历史会话不在此口径内) */ +/** 渠道聚合行:channel 为 CHANNELS 枚举(含 direct/unknown); + * pct = 占已分类会话比(4 位小数,列表内总和 ≈ 1;无分类行的历史会话不计入) */ export interface ChannelItem { channel: string sessions: number -} - -/** 今日热门页面 TOP8(page_view 按 path 聚合) */ -export interface PageItem { - path: string - pv: number + pct: number } /** 总览响应 */ export interface OverviewResp { - today: TodayCounts + header: OverviewHeader + cards: OverviewCards trend: TrendPoint[] - sources: SourceItem[] - /** 来源渠道聚合(sources 的归因升级版,原始域名口径保留在 sources) */ channels: ChannelItem[] - top_pages: PageItem[] } -/** 总览数据(site 可选,传 site_id) */ -export const getOverview = (site?: string) => - request.get({ url: '/admin/dashboard/overview', params: site ? { site } : {} }) +/** 总览数据(site 传 site_id 可选;range 默认 7d) */ +export const getOverview = (params: { site?: string; range?: OverviewRange } = {}) => + request.get({ url: '/admin/dashboard/overview', params }) diff --git a/admin/src/api/tracker/visitors.ts b/admin/src/api/tracker/visitors.ts index c811589..6be6154 100644 --- a/admin/src/api/tracker/visitors.ts +++ b/admin/src/api/tracker/visitors.ts @@ -34,6 +34,8 @@ export interface VisitorRow { last_source: string /** 首次访问落地页(空串=未采集,如直达首页;列表 tooltip / 详情识别卡用) */ first_landing_path: string + /** 最近一条事件的 path(总览「浏览页面」列;无事件访客为空串) */ + last_path: string /** 首触来源归因(first_touch 分类;无分类行/未重算的历史访客为 '',前端回落 first_source 展示) */ channel: string platform: string diff --git a/admin/src/utils/tracker/index.ts b/admin/src/utils/tracker/index.ts index f0ebdb0..152484e 100644 --- a/admin/src/utils/tracker/index.ts +++ b/admin/src/utils/tracker/index.ts @@ -80,6 +80,29 @@ export function channelBadge(row: ChannelBadgeRow): string { export const formatDateTime = (iso?: string | null): string => iso ? iso.replace('T', ' ').slice(0, 16) : '—' +/** 上海自然日今天(YYYY-MM-DD;后端 ISO 串同为上海时间,日期部分可直接比对) */ +const shanghaiDate = (): string => + new Intl.DateTimeFormat('en-CA', { timeZone: 'Asia/Shanghai' }).format(new Date()) + +/** YYYY-MM-DD 平移 n 天(按 UTC 毫秒运算,不受本地时区夏令时干扰) */ +const shiftDate = (date: string, days: number): string => { + const d = new Date(`${date}T00:00:00Z`) + d.setUTCDate(d.getUTCDate() + days) + return d.toISOString().slice(0, 10) +} + +/** ISO 时间 → 智能短时间:今天 HH:mm / 昨天 HH:mm / 更早 MM-DD HH:mm(空值 —) + * 后端已是上海时间,日期部分与 shanghaiDate 同口径比对,不做时区换算 */ +export const formatSmartTime = (iso?: string | null): string => { + if (!iso) return '—' + const date = iso.slice(0, 10) + const time = iso.slice(11, 16) + const today = shanghaiDate() + if (date === today) return `今天 ${time}` + if (date === shiftDate(today, -1)) return `昨天 ${time}` + return `${date.slice(5)} ${time}` +} + /** 毫秒 → x天x小时x分x秒(智能省略:为 0 的单位不显示;全 0 / 不足 1 秒显示 0秒) */ export const formatStay = (ms?: number | null): string => { const totalSec = Math.max(0, Math.floor((ms ?? 0) / 1000)) diff --git a/admin/src/views/tracker/dashboard/index.vue b/admin/src/views/tracker/dashboard/index.vue index 67bc481..de45cae 100644 --- a/admin/src/views/tracker/dashboard/index.vue +++ b/admin/src/views/tracker/dashboard/index.vue @@ -1,19 +1,23 @@ - + + + diff --git a/server/tracker/api/dashboard.py b/server/tracker/api/dashboard.py index 324ac68..0684129 100644 --- a/server/tracker/api/dashboard.py +++ b/server/tracker/api/dashboard.py @@ -1,19 +1,61 @@ +"""数据总览接口(客迹视觉 V1:周期卡 + 趋势双线 + 渠道分布)。 + +?range=today|7d|30d(默认 7d,非法值回落默认)+ ?site=(可选,缺省聚合全部站点)。 +- 周期卡四指标全口径排除爬虫,与上一个等长周期比较出 delta_pct(today 比昨天); + 上期值为 0 时 delta 为 null(前端显示 —)。 +- 趋势为日粒度:历史位读 DailyStat 聚合(缺日补零),今天一位用 daily_counts 实时值 + (聚合作业未跑也不缺当日)——与 refresh_daily 共享口径(Task8 审查结论)。 +- 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.utils import timezone -from rest_framework.views import APIView 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 from tracker.services.stats import daily_counts -METRICS = ("pv", "uv", "sessions", "new_visitors") +RANGE_DAYS = {"today": 1, "7d": 7, "30d": 30} +CARD_KEYS = ("visitors", "visits", "avg_stay_ms", "returning") -def _day_bounds(date): - """上海自然日窗口,与 daily_counts 同款 make_aware。""" - start = timezone.make_aware(datetime.combine(date, time.min)) - return start, start + timedelta(days=1) +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): @@ -25,14 +67,25 @@ class OverviewView(EnvelopeMixin, APIView): 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() - 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)}) + # 当前窗口与上期等长窗口(上海自然日对齐:[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): @@ -43,62 +96,43 @@ class OverviewView(EnvelopeMixin, APIView): q = q.filter(site=site) return q.count() - def _trend(self, site, today, today_counts): - """DailyStat 近 30 天(缺日补零;今天一位用 daily_counts 实时值覆盖——聚合作业未跑也不缺当日)。""" - days = 30 + def _trend(self, site, today, days): + """窗口每日 [{date, uv, sessions}]:历史位读 DailyStat(缺日补零), + 今天一位用 daily_counts 实时值覆盖——聚合作业未跑也不缺当日。""" 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"))} + uv=Sum("uv"), sessions=Sum("sessions"))} out = [] for i in range(days): date = start + timedelta(days=i) if date == today: + counts = daily_counts(site, today) out.append({"date": date.isoformat(), - **{k: today_counts[k] for k in METRICS}}) + "uv": counts["uv"], "sessions": counts["sessions"]}) else: r = rows.get(date, {}) out.append({"date": date.isoformat(), - **{k: r.get(k, 0) for k in METRICS}}) + "uv": r.get("uv") or 0, "sessions": r.get("sessions") or 0}) 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 的升级版,原始口径保留)。 + def _channels(self, site, start, end): + """窗口内会话按 session_entry 分类聚合(session.started_at 落窗)。 从分类表正向聚合 + Count(distinct session):无唯一约束下分类表偶发同键重复行 - 也不会把同一会话计两次。无分类行的会话(未重算的历史数据)不计入。""" - start, end = _day_bounds(today) + 也不会把同一会话计两次。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) - 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]] # 平局时次序跨请求稳定 + 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] diff --git a/server/tracker/api/visitors.py b/server/tracker/api/visitors.py index c70c31b..61af472 100644 --- a/server/tracker/api/visitors.py +++ b/server/tracker/api/visitors.py @@ -37,7 +37,8 @@ def _iso(dt): def _row(visitor, last_session, online_cutoff, ft=None): """列表/详情共用行字段。last_source 依赖最近会话(由调用方批量取好传入,防 N+1)。 - channel 等归因五字段来自该访客 first_touch 分类(无分类行 → 空串,前端回落 first_source 展示)。""" + channel 等归因五字段来自该访客 first_touch 分类(无分类行 → 空串,前端回落 first_source 展示)。 + last_path 是最近一条事件的 path(_annotate 的 Subquery 注解,同样无 N+1)。""" return { "visitor_id": visitor.visitor_id, # 站点归属:visitor.site 需随行 select_related("site"),否则逐行多一条查询 @@ -55,6 +56,7 @@ def _row(visitor, last_session, online_cutoff, ft=None): "last_source": _source(last_session.referrer_domain, last_session.utm) if last_session else "直接访问", # 无会话访客(仅采集异常场景) "first_landing_path": visitor.first_landing_path, # 列表首触来源 tooltip 用(详情本就输出) + "last_path": visitor.last_event_path or "", # 最近一条事件 path(总览"浏览页面"列) "channel": ft.channel if ft else "", "platform": ft.platform if ft else "", "variant": ft.variant if ft else "", @@ -87,18 +89,23 @@ def _session_summary(s): def _annotate(qs): - """会话数/有效停留/最近会话标记一次注解到位。 + """会话数/有效停留/最近会话/最近事件路径一次注解到位。 last_source 不逐行 sessions.order_by("-started_at").first()(页大小 20 即 N+1): 先用相关子查询把每个访客最近会话的 pk 标到行上(走 (visitor,-started_at) 索引), 分页后一次 IN 查询取回整页会话——全程只多 2 条 SQL。 + last_path 同理用相关子查询取最近一条事件的 path(走 (site,visitor,-occurred_at) 索引), + -pk 平局次序跨请求稳定;无事件访客注解为 NULL,_row 收敛为空串。 """ latest = (VisitSession.objects.filter(visitor=OuterRef("pk")) .order_by("-started_at", "-pk")) # -pk 平局次序跨请求稳定 + last_event = (Event.objects.filter(visitor=OuterRef("pk")) + .order_by("-occurred_at", "-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]), + last_event_path=Subquery(last_event.values("path")[:1]), ) diff --git a/server/tracker/tests/test_dashboard.py b/server/tracker/tests/test_dashboard.py index c70651d..9b8163c 100644 --- a/server/tracker/tests/test_dashboard.py +++ b/server/tracker/tests/test_dashboard.py @@ -14,6 +14,8 @@ User = get_user_model() NOW = timezone.localtime(timezone.now()) TODAY = NOW.date() YESTERDAY = TODAY - timedelta(days=1) +AT_3D = NOW - timedelta(days=3) # 7d 当前窗口内(近 3 天) +AT_10D = NOW - timedelta(days=10) # 7d 上期窗口内(前 7 天) @pytest.fixture @@ -32,16 +34,23 @@ def _auth(client, user): client.credentials(HTTP_AUTHORIZATION=f"Bearer {token.access_token}") -def _visitor(site, vid, last_seen=None): +def _visitor(site, vid, last_seen=None, first_seen=None): v = Visitor.objects.create(site=site, visitor_id=vid) - if last_seen is not None: # last_seen_at 是 auto_now_add,只能 queryset.update 绕开 - Visitor.objects.filter(pk=v.pk).update(last_seen_at=last_seen) + # first/last_seen_at 是 auto_now_add,只能 queryset.update 绕开 + fields = {} + if last_seen is not None: + fields["last_seen_at"] = last_seen + if first_seen is not None: + fields["first_seen_at"] = first_seen + if fields: + Visitor.objects.filter(pk=v.pk).update(**fields) return v -def _session(site, visitor, sid, referrer="", at=NOW): +def _session(site, visitor, sid, referrer="", at=NOW, stay_ms=0): return VisitSession.objects.create(site=site, visitor=visitor, session_id=sid, - started_at=at, ended_at=at + timedelta(minutes=5), referrer_domain=referrer) + started_at=at, ended_at=at + timedelta(minutes=5), referrer_domain=referrer, + stay_ms=stay_ms) def _events(site, visitor, session, n, path, at=NOW, prefix="e"): @@ -55,106 +64,174 @@ def test_overview_requires_auth(client): assert client.get("/api/admin/dashboard/overview").status_code == 401 -def test_overview_shape_and_numbers(client, member_user): +def test_overview_7d_cards_and_deltas(client, member_user): + """7d 四卡指标 + delta 正负 + 上期零(returning)为 null;爬虫全口径排除。""" _auth(client, member_user) site = Site.objects.create(name="s", domains=["a.com"]) - v1 = _visitor(site, "v1", last_seen=timezone.now()) # 活跃(5 分钟内) - v2 = _visitor(site, "v2", last_seen=timezone.now() - timedelta(hours=1)) # 不活跃 - s1 = _session(site, v1, "s1", referrer="google.com") - s2 = _session(site, v2, "s2", referrer="google.com") - s3 = _session(site, v1, "s3", referrer="") # 空来源 → 直接访问 - _events(site, v1, s1, 6, "/pricing", prefix="p") # /pricing x6 - _events(site, v1, s3, 2, "/", prefix="r") # / x2 - _events(site, v2, s2, 2, "/docs", prefix="d") # /docs x2 + # 本期(近 7 天):v1/v2 今天首访(新访客),v3 是 10 天前的老访客 3 天前回访 + v1 = _visitor(site, "v1", last_seen=timezone.now()) + v2 = _visitor(site, "v2", last_seen=timezone.now()) + v3 = _visitor(site, "v3", last_seen=timezone.now(), first_seen=AT_10D) + s1 = _session(site, v1, "s1", stay_ms=60000) + s2 = _session(site, v2, "s2", stay_ms=30000) + s3 = _session(site, v3, "s3", at=AT_3D) # stay 0 + _events(site, v1, s1, 2, "/a", prefix="a") + _events(site, v2, s2, 1, "/b", prefix="b") + _events(site, v3, s3, 1, "/c", at=AT_3D, prefix="c") + # 上期(前 7 天):v3 首访 + v4 三次会话——上期 visitors=2 / visits=4 / avg=20000 + sp3 = _session(site, v3, "sp3", at=AT_10D, stay_ms=10000) + _events(site, v3, sp3, 1, "/old3", at=AT_10D, prefix="o3") + v4 = _visitor(site, "v4", last_seen=AT_10D, first_seen=AT_10D) + for i, stay in enumerate((20000, 30000, 20000)): + sp = _session(site, v4, f"sp4-{i}", at=AT_10D, stay_ms=stay) + _events(site, v4, sp, 1, "/old4", at=AT_10D, prefix=f"o4{i}") + # 爬虫:今天有大额停留与事件,任何口径都不得计入(事件行同步标记 is_bot,与 ingest 同口径) + bot = _visitor(site, "bot-v", last_seen=timezone.now()) + Visitor.objects.filter(pk=bot.pk).update(is_bot=True) + sb = _session(site, bot, "sb", stay_ms=999000) + _events(site, bot, sb, 5, "/bot", prefix="bt") + Event.objects.filter(visitor=bot).update(is_bot=True) - r = client.get("/api/admin/dashboard/overview") + r = client.get("/api/admin/dashboard/overview") # 缺省 range=7d assert r.status_code == 200 d = r.json()["data"] - assert d["today"] == {"pv": 10, "uv": 2, "sessions": 3, - "new_visitors": 2, "active_now": 1} + # 本期:visitors=3(v1/v2/v3)、visits=3、avg=(60k+30k+0)/3=30k、returning=3-2 新=1 + # 上期:visitors=2(v3/v4)、visits=4、avg=80k/4=20k、returning=2-2 新=0 → delta null + assert d["cards"]["visitors"] == {"value": 3, "delta_pct": 50.0} # 正 + assert d["cards"]["visits"] == {"value": 3, "delta_pct": -25.0} # 负 + assert d["cards"]["avg_stay_ms"] == {"value": 30000, "delta_pct": 50.0} + assert d["cards"]["returning"] == {"value": 1, "delta_pct": None} # 上期 0 → null + # 在线:v1/v2/v3 活跃(bot 排除、v4 十天前不在线) + assert d["header"] == {"online": 3, "active_now": 3} # active_now 兼容保留 + # 趋势:7 点日粒度,历史无 DailyStat 补零,今天一位用实时值(uv=2、sessions=2,bot 排除) trend = d["trend"] - assert len(trend) == 30 - assert trend[-1]["date"] == TODAY.isoformat() - assert trend[-1]["pv"] == 10 # 今天位用实时值 - assert trend[-2]["pv"] == 0 # 昨天无 DailyStat → 补零 - assert d["sources"][0] == {"source": "google.com", "sessions": 2} - assert d["sources"][-1] == {"source": "直接访问", "sessions": 1} - assert d["top_pages"][0] == {"path": "/pricing", "pv": 6} + assert len(trend) == 7 + assert trend[0]["date"] == (TODAY - timedelta(days=6)).isoformat() + assert trend[-1] == {"date": TODAY.isoformat(), "uv": 2, "sessions": 2} + assert trend[-2] == {"date": YESTERDAY.isoformat(), "uv": 0, "sessions": 0} + assert d["channels"] == [] # 无分类行 → 空列表 + + +def test_overview_prev_zero_delta_null(client, member_user): + """上期完全无数据:四卡 delta_pct 全为 null,卡值正常。""" + _auth(client, member_user) + site = Site.objects.create(name="s", domains=["a.com"]) + v1 = _visitor(site, "v1", last_seen=timezone.now()) + s1 = _session(site, v1, "s1", stay_ms=50000) + _events(site, v1, s1, 2, "/a", prefix="a") + + d = client.get("/api/admin/dashboard/overview").json()["data"] + assert d["cards"] == { + "visitors": {"value": 1, "delta_pct": None}, + "visits": {"value": 1, "delta_pct": None}, + "avg_stay_ms": {"value": 50000, "delta_pct": None}, + "returning": {"value": 0, "delta_pct": None}, # 新访客 → 回访 0,同样上期零 + } + + +def test_overview_channels_pct(client, member_user): + """channels:窗口内 session_entry 分类聚合,pct 占已分类会话比、总和 ≈ 1。""" + _auth(client, member_user) + site = Site.objects.create(name="s", domains=["a.com"]) + v1 = _visitor(site, "v1", last_seen=timezone.now()) + v2 = _visitor(site, "v2", last_seen=timezone.now()) + s1 = _session(site, v1, "s1", referrer="www.bing.com") + s2 = _session(site, v1, "s2", referrer="www.bing.com", at=AT_3D) + s3 = _session(site, v2, "s3", referrer="chatgpt.com") + s4 = _session(site, v2, "s4") # 直接访问 + s5 = _session(site, v2, "s5", referrer="t.co", at=AT_10D) # 上期:不计入 + s6 = _session(site, v2, "s6", referrer="example.com") # 无分类行:不计入 + for s, ch, pf in ((s1, "natural_search", "Bing"), (s2, "natural_search", "Bing"), + (s3, "ai_referral", "ChatGPT"), (s4, "direct", "")): + SourceClassification.objects.create(site=site, visitor=s.visitor, session=s, + scope="session_entry", channel=ch, platform=pf) + SourceClassification.objects.create(site=site, visitor=v2, session=s5, + scope="session_entry", channel="social", platform="Twitter") + _events(site, v1, s1, 1, "/", prefix="x") + + d = client.get("/api/admin/dashboard/overview").json()["data"] + assert d["channels"] == [ + {"channel": "natural_search", "sessions": 2, "pct": pytest.approx(0.5)}, + {"channel": "ai_referral", "sessions": 1, "pct": pytest.approx(0.25)}, + {"channel": "direct", "sessions": 1, "pct": pytest.approx(0.25)}, # 平局按名稳定 + ] + assert sum(c["pct"] for c in d["channels"]) == pytest.approx(1.0) + + +def test_overview_range_today(client, member_user): + """range=today:卡按今天口径、delta 比昨天;趋势单点;非法 range 回落 7d。""" + _auth(client, member_user) + site = Site.objects.create(name="s", domains=["a.com"]) + vy = _visitor(site, "vy", last_seen=timezone.now(), first_seen=NOW - timedelta(days=1)) + sy = _session(site, vy, "sy", at=NOW - timedelta(days=1), stay_ms=60000) + _events(site, vy, sy, 2, "/y", at=NOW - timedelta(days=1), prefix="y") + vt = _visitor(site, "vt", last_seen=timezone.now()) + st = _session(site, vt, "st", stay_ms=30000) + _events(site, vt, st, 1, "/t", prefix="t") + + d = client.get("/api/admin/dashboard/overview", {"range": "today"}).json()["data"] + assert d["trend"] == [{"date": TODAY.isoformat(), "uv": 1, "sessions": 1}] + assert d["cards"]["visitors"] == {"value": 1, "delta_pct": 0.0} # 昨天也是 1 + assert d["cards"]["visits"] == {"value": 1, "delta_pct": 0.0} + assert d["cards"]["avg_stay_ms"] == {"value": 30000, "delta_pct": -50.0} + assert d["cards"]["returning"]["delta_pct"] is None # 两天都是全新访客 + + assert len(client.get("/api/admin/dashboard/overview").json()["data"]["trend"]) == 7 + d = client.get("/api/admin/dashboard/overview", {"range": "abc"}).json()["data"] + assert len(d["trend"]) == 7 # 非法值回落 7d + d = client.get("/api/admin/dashboard/overview", {"range": "30d"}).json()["data"] + assert len(d["trend"]) == 30 def test_overview_trend_uses_dailystat_history(client, member_user): + """7d 趋势历史位读 DailyStat 聚合(跨站求和),今天位用实时值。""" _auth(client, member_user) site_a = Site.objects.create(name="a", domains=["a.com"]) site_b = Site.objects.create(name="b", domains=["b.com"]) yn = NOW - timedelta(days=1) # 昨天同一本地时刻,必落上海昨日窗口 - # a 站昨天 3 事件走真实 refresh_daily 落 DailyStat va = Visitor.objects.create(site=site_a, visitor_id="va") sa = _session(site_a, va, "sa", at=yn) _events(site_a, va, sa, 3, "/", at=yn, prefix="ya") refresh_daily(site_a, YESTERDAY) - # b 站昨天 4 事件同样走 refresh_daily——两站各落一行,验证 site=None 的跨站 Sum vb = Visitor.objects.create(site=site_b, visitor_id="vb") sb = _session(site_b, vb, "sb", at=yn) _events(site_b, vb, sb, 4, "/", at=yn, prefix="yb") refresh_daily(site_b, YESTERDAY) d = client.get("/api/admin/dashboard/overview").json()["data"] - assert d["trend"][-2] == {"date": YESTERDAY.isoformat(), "pv": 7, "uv": 2, - "sessions": 2, "new_visitors": 0} # 3+4 跨站点求和 - assert d["trend"][-1]["pv"] == 0 # 今天无事件,实时覆盖为 0 - - -def test_overview_channels_aggregation(client, member_user): - """channels:当天会话按 session_entry 分类的 channel 聚合(与 sources 并存,互不影响)。""" - _auth(client, member_user) - site = Site.objects.create(name="s", domains=["a.com"]) - v1 = _visitor(site, "v1", last_seen=timezone.now()) - v2 = _visitor(site, "v2", last_seen=timezone.now()) - s1 = _session(site, v1, "s1", referrer="www.bing.com") - s2 = _session(site, v1, "s2", referrer="www.bing.com") - s3 = _session(site, v2, "s3", referrer="chatgpt.com") - s4 = _session(site, v2, "s4", referrer="") # 直接访问 - SourceClassification.objects.create(site=site, visitor=v1, session=s1, - scope="session_entry", channel="natural_search", platform="Bing") - SourceClassification.objects.create(site=site, visitor=v1, session=s2, - scope="session_entry", channel="natural_search", platform="Bing") - SourceClassification.objects.create(site=site, visitor=v2, session=s3, - scope="session_entry", channel="ai_referral", platform="ChatGPT") - SourceClassification.objects.create(site=site, visitor=v2, session=s4, - scope="session_entry", channel="direct", resolution="unknown") - _events(site, v1, s1, 2, "/", prefix="a") - - d = client.get("/api/admin/dashboard/overview").json()["data"] - assert d["channels"] == [{"channel": "natural_search", "sessions": 2}, - {"channel": "ai_referral", "sessions": 1}, - {"channel": "direct", "sessions": 1}] # 倒序 top8,平局按名稳定 - assert d["sources"][0] == {"source": "www.bing.com", "sessions": 2} # 原口径保留 + trend = d["trend"] + assert len(trend) == 7 + assert trend[-2] == {"date": YESTERDAY.isoformat(), "uv": 2, "sessions": 2} # 跨站求和 + assert trend[-1] == {"date": TODAY.isoformat(), "uv": 0, "sessions": 0} # 今天无事件 def test_overview_site_filter_and_404(client, member_user): _auth(client, member_user) site_a = Site.objects.create(name="a", domains=["a.com"]) site_b = Site.objects.create(name="b", domains=["b.com"]) - va = _visitor(site_a, "va") - vb = _visitor(site_b, "vb") + va = _visitor(site_a, "va", last_seen=timezone.now()) + vb = _visitor(site_b, "vb", last_seen=timezone.now()) sa = _session(site_a, va, "sa", referrer="google.com") - sb = _session(site_b, vb, "sb", referrer="") + sb = _session(site_b, vb, "sb") _events(site_a, va, sa, 2, "/", prefix="a") _events(site_b, vb, sb, 5, "/b", prefix="b") + SourceClassification.objects.create(site=site_b, visitor=vb, session=sb, + scope="session_entry", channel="direct", resolution="unknown") # a 站昨天有 DailyStat 行——过滤 site_b 时不得混入 yn = NOW - timedelta(days=1) _events(site_a, va, sa, 3, "/", at=yn, prefix="ay") refresh_daily(site_a, YESTERDAY) d = client.get("/api/admin/dashboard/overview").json()["data"] - assert d["today"]["pv"] == 7 # 缺省聚合全部站点 + assert d["cards"]["visitors"]["value"] == 2 # 缺省聚合全部站点 + assert d["header"]["online"] == 2 d = client.get("/api/admin/dashboard/overview", {"site": site_b.site_id}).json()["data"] - assert d["today"]["pv"] == 5 and d["today"]["uv"] == 1 - assert d["today"]["active_now"] == 1 # 两访客 last_seen 均为 now,但只计本站 - assert d["sources"] == [{"source": "直接访问", "sessions": 1}] - assert d["top_pages"][0] == {"path": "/b", "pv": 5} - assert d["trend"][-2]["pv"] == 0 # a 站昨日行被站点过滤排除 - assert d["trend"][-1]["pv"] == 5 # 今天位仍是本站实时值 + assert d["cards"]["visitors"]["value"] == 1 and d["cards"]["visits"]["value"] == 1 + assert d["header"]["online"] == 1 # 只计本站 + assert d["channels"] == [{"channel": "direct", "sessions": 1, + "pct": pytest.approx(1.0)}] + assert d["trend"][-2] == {"date": YESTERDAY.isoformat(), "uv": 0, "sessions": 0} + assert d["trend"][-1] == {"date": TODAY.isoformat(), "uv": 1, "sessions": 1} r = client.get("/api/admin/dashboard/overview", {"site": "site_nope"}) assert r.status_code == 404 diff --git a/server/tracker/tests/test_visitors.py b/server/tracker/tests/test_visitors.py index f202627..aeb9268 100644 --- a/server/tracker/tests/test_visitors.py +++ b/server/tracker/tests/test_visitors.py @@ -78,8 +78,8 @@ def test_list_fields_and_default_bot_excluded(client, member_user): "device_type", "os", "browser", "language", "first_seen_at", "last_seen_at", "visit_count", "active_days", "session_count", "total_stay_ms", "first_source", "last_source", "first_landing_path", - "online", "channel", "platform", "variant", "search_term", "resolution", - "device_model"} + "last_path", "online", "channel", "platform", "variant", "search_term", + "resolution", "device_model"} assert row["country"] == "中国" and row["os"] == "Windows" and row["browser"] == "Chrome" assert row["session_count"] == 2 assert row["total_stay_ms"] == 150000 # 120000+30000,有效停留口径 @@ -146,6 +146,26 @@ def test_list_site_fields_and_site_filter(client, member_user): assert d["total"] == 1 and [x["visitor_id"] for x in d["list"]] == ["vb"] # 只剩本站访客 +def test_list_last_path(client, member_user): + """列表行 last_path:该访客最近一条事件的 path(Subquery 注解,无 N+1); + 无事件访客为空串。""" + _auth(client, member_user) + site = Site.objects.create(name="s", domains=["a.com"]) + v1 = _visitor(site, "v1", last_seen=timezone.now()) + v2 = _visitor(site, "v2", last_seen=timezone.now() - timedelta(hours=1)) + s1 = _session(site, v1, "s1", at=NOW - timedelta(hours=2)) + _event(site, v1, s1, "e1", "page_view", NOW - timedelta(hours=2), path="/old") + _event(site, v1, s1, "e2", "page_view", NOW - timedelta(minutes=90), path="/stay") + _event(site, v1, s1, "e3", "page_stay", NOW - timedelta(minutes=30), path="/stay", + active_ms=1000) # occurred_at 早于 e2 → 不改判"最近" + _event(site, v1, s1, "e4", "page_view", NOW, path="/newest") + + d = client.get("/api/admin/visitors").json()["data"] + by_id = {x["visitor_id"]: x for x in d["list"]} + assert by_id["v1"]["last_path"] == "/newest" # 最近一条事件的 path + assert by_id["v2"]["last_path"] == "" # 无事件 → 空串(前端显示 —) + + def test_list_site_not_found(client, member_user): _auth(client, member_user) site = Site.objects.create(name="s", domains=["a.com"]) @@ -198,6 +218,7 @@ def test_detail_shape(client, member_user): assert d["session_count"] == 2 and d["total_stay_ms"] == 90000 assert d["first_source"] == "google.com" assert d["last_source"] == "wechat" # 最近会话 s-new:referrer 空 → utm 兜底 + assert d["last_path"] == "/b" # 最近一条事件 e3 的 path 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",