feat(admin): 数据总览按客迹视觉V1重做(周期卡/趋势双线/渠道条形/最近访问)

Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
浅念
2026-09-17 12:18:37 +08:00
parent 132e2feb75
commit 8f20d8d9a2
8 changed files with 955 additions and 300 deletions
+143 -66
View File
@@ -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