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:
浅念
2026-09-15 13:38:01 +08:00
parent f69822859e
commit cc1ea6805b
8 changed files with 153 additions and 7 deletions
@@ -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'),
),
]
+14 -6
View File
@@ -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)
+26
View File
@@ -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
+12 -1
View File
@@ -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
+30
View File
@@ -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)