feat: 日聚合服务与命令 + 索引迁移打包(Task3 审查结论)
- refresh_daily:按上海自然日聚合 pv/uv/sessions/new_visitors,update_or_create 幂等 - refresh_stats 管理命令:回刷近 N 天(生产由 1Panel 每日 00:10 调度,见 Task 22) - 索引打包(0002):Event/VisitSession 增加 (site, 时间) 日聚合索引; 删 5 个冗余单列 FK 索引(Event.site/session、VisitSession.site/visitor、DailyStat.site), 保留 Event.visitor/Visitor.site;sqlite PRAGMA 实证级联索引完整、EXPLAIN 走新索引 - test_models:IntegrityError 断言收紧 + Site 级联删除测试 Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,24 @@
|
||||
from datetime import timedelta
|
||||
from django.core.management.base import BaseCommand
|
||||
from django.utils import timezone
|
||||
from tracker.models import Site
|
||||
from tracker.services.stats import refresh_daily
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
# 生产调度:1Panel 计划任务每日 00:10 执行
|
||||
# docker exec <web容器> python manage.py refresh_stats --days 2
|
||||
# (回刷含今天在内的近 N 天,详见 Task 22 README)
|
||||
help = "回刷近 N 天日聚合(含今天)"
|
||||
|
||||
def add_arguments(self, parser):
|
||||
parser.add_argument("--days", type=int, default=2)
|
||||
|
||||
def handle(self, *args, **opts):
|
||||
today = timezone.localdate()
|
||||
total = 0
|
||||
for site in Site.objects.filter(is_active=True):
|
||||
for i in range(opts["days"]):
|
||||
stat = refresh_daily(site, today - timedelta(days=i))
|
||||
total += 1
|
||||
self.stdout.write(f"refreshed {total} site-days")
|
||||
@@ -0,0 +1,47 @@
|
||||
# Generated by Django 5.2.17 on 2026-09-15 05:36
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('tracker', '0001_initial'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='dailystat',
|
||||
name='site',
|
||||
field=models.ForeignKey(db_index=False, on_delete=django.db.models.deletion.CASCADE, related_name='daily_stats', to='tracker.site'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='event',
|
||||
name='session',
|
||||
field=models.ForeignKey(db_index=False, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='events', to='tracker.visitsession'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='event',
|
||||
name='site',
|
||||
field=models.ForeignKey(db_index=False, on_delete=django.db.models.deletion.CASCADE, related_name='events', to='tracker.site'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='visitsession',
|
||||
name='site',
|
||||
field=models.ForeignKey(db_index=False, on_delete=django.db.models.deletion.CASCADE, related_name='sessions', to='tracker.site'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='visitsession',
|
||||
name='visitor',
|
||||
field=models.ForeignKey(db_index=False, on_delete=django.db.models.deletion.CASCADE, related_name='sessions', to='tracker.visitor'),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name='event',
|
||||
index=models.Index(fields=['site', 'occurred_at'], name='event_site_id_f158ab_idx'),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name='visitsession',
|
||||
index=models.Index(fields=['site', 'started_at'], name='visit_sessi_site_id_73fae1_idx'),
|
||||
),
|
||||
]
|
||||
@@ -53,8 +53,9 @@ class Visitor(models.Model):
|
||||
|
||||
|
||||
class VisitSession(models.Model):
|
||||
site = models.ForeignKey(Site, on_delete=models.CASCADE, related_name="sessions")
|
||||
visitor = models.ForeignKey(Visitor, on_delete=models.CASCADE, related_name="sessions")
|
||||
# db_index=False:单列 FK 索引冗余——site 由 (site, started_at)、visitor 由 (visitor, -started_at) 复合索引前缀覆盖
|
||||
site = models.ForeignKey(Site, on_delete=models.CASCADE, related_name="sessions", db_index=False)
|
||||
visitor = models.ForeignKey(Visitor, on_delete=models.CASCADE, related_name="sessions", db_index=False)
|
||||
session_id = models.CharField(max_length=64)
|
||||
started_at = models.DateTimeField()
|
||||
ended_at = models.DateTimeField()
|
||||
@@ -68,13 +69,19 @@ class VisitSession(models.Model):
|
||||
class Meta:
|
||||
db_table = "visit_session"
|
||||
constraints = [models.UniqueConstraint(fields=["site", "session_id"], name="uq_site_session")]
|
||||
indexes = [models.Index(fields=["visitor", "-started_at"])]
|
||||
indexes = [
|
||||
models.Index(fields=["visitor", "-started_at"]),
|
||||
models.Index(fields=["site", "started_at"]), # 按日聚合/时间线查询
|
||||
]
|
||||
|
||||
|
||||
class Event(models.Model):
|
||||
site = models.ForeignKey(Site, on_delete=models.CASCADE, related_name="events")
|
||||
# site/session 的单列 FK 索引冗余:site 由 (site, visitor/-occurred_at)、(site, event_name, -occurred_at)、
|
||||
# (site, occurred_at) 前缀覆盖,session 由 (session, occurred_at) 前缀覆盖,故 db_index=False。
|
||||
# visitor 保留默认索引:无其他以 visitor 打头的索引,级联删除(visitor 反向删 events)需要它。
|
||||
site = models.ForeignKey(Site, on_delete=models.CASCADE, related_name="events", db_index=False)
|
||||
visitor = models.ForeignKey(Visitor, on_delete=models.CASCADE, related_name="events")
|
||||
session = models.ForeignKey(VisitSession, on_delete=models.CASCADE, related_name="events", null=True)
|
||||
session = models.ForeignKey(VisitSession, on_delete=models.CASCADE, related_name="events", null=True, db_index=False)
|
||||
event_id = models.CharField(max_length=64)
|
||||
event_name = models.CharField(max_length=50)
|
||||
occurred_at = models.DateTimeField()
|
||||
@@ -96,11 +103,12 @@ class Event(models.Model):
|
||||
models.Index(fields=["site", "visitor", "-occurred_at"]),
|
||||
models.Index(fields=["session", "occurred_at"]),
|
||||
models.Index(fields=["site", "event_name", "-occurred_at"]),
|
||||
models.Index(fields=["site", "occurred_at"]), # refresh_daily 按日聚合扫描
|
||||
]
|
||||
|
||||
|
||||
class DailyStat(models.Model):
|
||||
site = models.ForeignKey(Site, on_delete=models.CASCADE, related_name="daily_stats")
|
||||
site = models.ForeignKey(Site, on_delete=models.CASCADE, related_name="daily_stats", db_index=False) # uq_site_date 前缀覆盖
|
||||
date = models.DateField()
|
||||
pv = models.PositiveIntegerField(default=0)
|
||||
uv = models.PositiveIntegerField(default=0)
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
from datetime import datetime, time, timedelta
|
||||
from django.utils import timezone
|
||||
from tracker.models import DailyStat, Event, VisitSession, Visitor
|
||||
|
||||
|
||||
def refresh_daily(site, date) -> DailyStat:
|
||||
"""重算某站点某自然日的聚合(幂等,update_or_create)。
|
||||
|
||||
日界取 TIME_ZONE(Asia/Shanghai)自然日:make_aware(datetime.combine(date, time.min))。
|
||||
口径:pv/uv 均从事件表统计(排除 is_bot),uv=distinct visitor,与 pv 同源;
|
||||
sessions 按会话 started_at 落入当日且访客非爬虫;new_visitors 按 Visitor.first_seen_at 落入当日。
|
||||
"""
|
||||
day_start = timezone.make_aware(datetime.combine(date, time.min))
|
||||
day_end = day_start + timedelta(days=1)
|
||||
pv = Event.objects.filter(site=site, is_bot=False,
|
||||
occurred_at__gte=day_start, occurred_at__lt=day_end).count()
|
||||
uv = Event.objects.filter(site=site, is_bot=False, occurred_at__gte=day_start,
|
||||
occurred_at__lt=day_end).values("visitor").distinct().count()
|
||||
sessions = VisitSession.objects.filter(site=site, visitor__is_bot=False,
|
||||
started_at__gte=day_start,
|
||||
started_at__lt=day_end).count()
|
||||
new_v = Visitor.objects.filter(site=site, first_seen_at__gte=day_start,
|
||||
first_seen_at__lt=day_end).count()
|
||||
obj, _ = DailyStat.objects.update_or_create(site=site, date=date,
|
||||
defaults={"pv": pv, "uv": uv, "sessions": sessions, "new_visitors": new_v})
|
||||
return obj
|
||||
@@ -1,4 +1,5 @@
|
||||
import pytest
|
||||
from django.db import IntegrityError
|
||||
from tracker.models import Site, Visitor, VisitSession, Event
|
||||
|
||||
pytestmark = pytest.mark.django_db
|
||||
@@ -22,6 +23,16 @@ def test_event_unique_site_event_id():
|
||||
started_at="2026-09-15T10:00:00Z", ended_at="2026-09-15T10:01:00Z")
|
||||
Event.objects.create(site=site, visitor=v, session=s, event_id="e1",
|
||||
event_name="page_view", occurred_at="2026-09-15T10:00:00Z", path="/")
|
||||
with pytest.raises(Exception):
|
||||
with pytest.raises(IntegrityError):
|
||||
Event.objects.create(site=site, visitor=v, session=s, event_id="e1",
|
||||
event_name="page_view", occurred_at="2026-09-15T10:01:00Z", path="/")
|
||||
|
||||
def test_site_cascade_deletes_children():
|
||||
site = Site.objects.create(name="s", domains=["a.com"])
|
||||
v = Visitor.objects.create(site=site, visitor_id="v")
|
||||
s = VisitSession.objects.create(site=site, visitor=v, session_id="s",
|
||||
started_at="2026-09-15T10:00:00Z", ended_at="2026-09-15T10:01:00Z")
|
||||
Event.objects.create(site=site, visitor=v, session=s, event_id="e",
|
||||
event_name="page_view", occurred_at="2026-09-15T10:00:00Z", path="/")
|
||||
site.delete()
|
||||
assert Visitor.objects.count() == 0 and VisitSession.objects.count() == 0 and Event.objects.count() == 0
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
from datetime import timedelta
|
||||
from django.utils import timezone
|
||||
from tracker.models import Site, Visitor, VisitSession, Event
|
||||
from tracker.services.stats import refresh_daily
|
||||
|
||||
# 用上海本地时刻做基准:refresh_daily 的日界是上海自然日(make_aware),
|
||||
# 若直接用 UTC 的 timezone.now(),在本地 00:00-08:00 之间跑测试时 NOW.date() 会错一天
|
||||
NOW = timezone.localtime(timezone.now())
|
||||
YESTERDAY = NOW - timedelta(days=1)
|
||||
|
||||
def _mk(site, vid, sid, day, n_events, stay=0):
|
||||
v, _ = Visitor.objects.get_or_create(site=site, visitor_id=vid)
|
||||
sess = VisitSession.objects.create(site=site, visitor=v, session_id=sid,
|
||||
started_at=day, ended_at=day + timedelta(minutes=5),
|
||||
stay_ms=stay, event_count=n_events)
|
||||
for i in range(n_events):
|
||||
Event.objects.create(site=site, visitor=v, session=sess, event_id=f"{vid}-{sid}-{day:%Y%m%d}-{i}",
|
||||
event_name="page_view", occurred_at=day + timedelta(seconds=i), path="/")
|
||||
|
||||
def test_refresh_daily_two_days(db):
|
||||
site = Site.objects.create(name="s", domains=["a.com"])
|
||||
_mk(site, "v1", "s1", YESTERDAY, 3) # v1 昨天 3 事件
|
||||
_mk(site, "v1", "s2", NOW, 2, stay=8000) # v1 今天 2 事件
|
||||
_mk(site, "v2", "s3", NOW, 4) # v2 今天 4 事件
|
||||
y = refresh_daily(site, YESTERDAY.date())
|
||||
t = refresh_daily(site, NOW.date())
|
||||
# new_visitors 口径 = first_seen_at 落入当日。测试里 v1 虽有昨日事件,但访客记录是测试运行时(今天)建档的
|
||||
# (auto_now_add),故昨天 new=0、今天 new=2(v1+v2)——按 first_seen_at 真实语义断言
|
||||
assert (y.pv, y.uv, y.sessions, y.new_visitors) == (3, 1, 1, 0)
|
||||
assert (t.pv, t.uv, t.sessions, t.new_visitors) == (6, 2, 2, 2) # 今天:pv=6 uv=2(v1+v2) sessions=2 new=2(v1+v2)
|
||||
Reference in New Issue
Block a user