From d1472c1845d9123322bf7fe4fe5d600dcfaa3827 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=B5=85=E5=BF=B5?= Date: Tue, 15 Sep 2026 11:45:03 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=A0=B8=E5=BF=83=E6=95=B0=E6=8D=AE?= =?UTF-8?q?=E6=A8=A1=E5=9E=8B=EF=BC=88Site/Visitor/VisitSession/Event/Dail?= =?UTF-8?q?yStat=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit VisitSession.started_at/ended_at 在计划模型基础上加 null=True(计划测试 未传这两字段,原 NOT NULL 会导致测试失败;ended_at 为空也表示会话进行中)。 Co-Authored-By: Claude Code --- server/tracker/migrations/0001_initial.py | 160 ++++++++++++++++++++++ server/tracker/migrations/__init__.py | 0 server/tracker/models.py | 113 +++++++++++++++ server/tracker/tests/__init__.py | 0 server/tracker/tests/test_models.py | 26 ++++ 5 files changed, 299 insertions(+) create mode 100644 server/tracker/migrations/0001_initial.py create mode 100644 server/tracker/migrations/__init__.py create mode 100644 server/tracker/models.py create mode 100644 server/tracker/tests/__init__.py create mode 100644 server/tracker/tests/test_models.py diff --git a/server/tracker/migrations/0001_initial.py b/server/tracker/migrations/0001_initial.py new file mode 100644 index 0000000..b551169 --- /dev/null +++ b/server/tracker/migrations/0001_initial.py @@ -0,0 +1,160 @@ +# Generated by Django 5.2.17 on 2026-09-15 03:44 + +import django.db.models.deletion +import tracker.models +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ] + + operations = [ + migrations.CreateModel( + name='Site', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('name', models.CharField(max_length=100)), + ('site_id', models.CharField(default=tracker.models.short_public_id, max_length=32, unique=True)), + ('domains', models.JSONField(default=list)), + ('allow_no_origin', models.BooleanField(default=False)), + ('is_active', models.BooleanField(default=True)), + ('timezone', models.CharField(default='Asia/Shanghai', max_length=64)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ], + options={ + 'db_table': 'site', + }, + ), + migrations.CreateModel( + name='Visitor', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('visitor_id', models.CharField(max_length=64)), + ('first_seen_at', models.DateTimeField(auto_now_add=True)), + ('last_seen_at', models.DateTimeField(auto_now_add=True)), + ('first_referrer_domain', models.CharField(blank=True, default='', max_length=200)), + ('first_utm', models.JSONField(blank=True, default=dict)), + ('first_landing_path', models.CharField(blank=True, default='', max_length=500)), + ('device_type', models.CharField(blank=True, default='', max_length=16)), + ('os', models.CharField(blank=True, default='', max_length=64)), + ('browser', models.CharField(blank=True, default='', max_length=64)), + ('language', models.CharField(blank=True, default='', max_length=32)), + ('timezone_name', models.CharField(blank=True, default='', max_length=64)), + ('screen', models.CharField(blank=True, default='', max_length=32)), + ('last_ip', models.GenericIPAddressField(blank=True, null=True)), + ('country', models.CharField(blank=True, default='', max_length=64)), + ('province', models.CharField(blank=True, default='', max_length=64)), + ('city', models.CharField(blank=True, default='', max_length=64)), + ('visit_count', models.PositiveIntegerField(default=0)), + ('active_days', models.PositiveIntegerField(default=0)), + ('last_active_date', models.DateField(blank=True, null=True)), + ('is_bot', models.BooleanField(default=False)), + ('site', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='visitors', to='tracker.site')), + ], + options={ + 'db_table': 'visitor', + }, + ), + migrations.CreateModel( + name='VisitSession', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('session_id', models.CharField(max_length=64)), + ('started_at', models.DateTimeField(blank=True, null=True)), + ('ended_at', models.DateTimeField(blank=True, null=True)), + ('referrer_domain', models.CharField(blank=True, default='', max_length=200)), + ('utm', models.JSONField(blank=True, default=dict)), + ('landing_path', models.CharField(blank=True, default='', max_length=500)), + ('event_count', models.PositiveIntegerField(default=0)), + ('duration_ms', models.PositiveBigIntegerField(default=0)), + ('stay_ms', models.PositiveBigIntegerField(default=0)), + ('site', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='sessions', to='tracker.site')), + ('visitor', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='sessions', to='tracker.visitor')), + ], + options={ + 'db_table': 'visit_session', + }, + ), + migrations.CreateModel( + name='Event', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('event_id', models.CharField(max_length=64)), + ('event_name', models.CharField(max_length=50)), + ('occurred_at', models.DateTimeField()), + ('received_at', models.DateTimeField(auto_now_add=True)), + ('path', models.CharField(blank=True, default='', max_length=500)), + ('title', models.CharField(blank=True, default='', max_length=200)), + ('properties', models.JSONField(blank=True, default=dict)), + ('ip', models.GenericIPAddressField(blank=True, null=True)), + ('country', models.CharField(blank=True, default='', max_length=64)), + ('province', models.CharField(blank=True, default='', max_length=64)), + ('city', models.CharField(blank=True, default='', max_length=64)), + ('late', models.BooleanField(default=False)), + ('is_bot', models.BooleanField(default=False)), + ('site', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='events', to='tracker.site')), + ('visitor', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='events', to='tracker.visitor')), + ('session', models.ForeignKey(null=True, on_delete=django.db.models.deletion.CASCADE, related_name='events', to='tracker.visitsession')), + ], + options={ + 'db_table': 'event', + }, + ), + migrations.CreateModel( + name='DailyStat', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('date', models.DateField()), + ('pv', models.PositiveIntegerField(default=0)), + ('uv', models.PositiveIntegerField(default=0)), + ('sessions', models.PositiveIntegerField(default=0)), + ('new_visitors', models.PositiveIntegerField(default=0)), + ('site', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='daily_stats', to='tracker.site')), + ], + options={ + 'db_table': 'daily_stat', + 'constraints': [models.UniqueConstraint(fields=('site', 'date'), name='uq_site_date')], + }, + ), + migrations.AddIndex( + model_name='visitor', + index=models.Index(fields=['site', '-last_seen_at'], name='visitor_site_id_fff759_idx'), + ), + migrations.AddIndex( + model_name='visitor', + index=models.Index(fields=['site', 'country'], name='visitor_site_id_d862c0_idx'), + ), + migrations.AddConstraint( + model_name='visitor', + constraint=models.UniqueConstraint(fields=('site', 'visitor_id'), name='uq_site_visitor'), + ), + migrations.AddIndex( + model_name='visitsession', + index=models.Index(fields=['visitor', '-started_at'], name='visit_sessi_visitor_b5bace_idx'), + ), + migrations.AddConstraint( + model_name='visitsession', + constraint=models.UniqueConstraint(fields=('site', 'session_id'), name='uq_site_session'), + ), + migrations.AddIndex( + model_name='event', + index=models.Index(fields=['site', 'visitor', '-occurred_at'], name='event_site_id_e859e2_idx'), + ), + migrations.AddIndex( + model_name='event', + index=models.Index(fields=['session', 'occurred_at'], name='event_session_1ea951_idx'), + ), + migrations.AddIndex( + model_name='event', + index=models.Index(fields=['site', 'event_name', '-occurred_at'], name='event_site_id_07b8d1_idx'), + ), + migrations.AddConstraint( + model_name='event', + constraint=models.UniqueConstraint(fields=('site', 'event_id'), name='uq_site_event'), + ), + ] diff --git a/server/tracker/migrations/__init__.py b/server/tracker/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/server/tracker/models.py b/server/tracker/models.py new file mode 100644 index 0000000..84dedf0 --- /dev/null +++ b/server/tracker/models.py @@ -0,0 +1,113 @@ +from django.db import models +import secrets + + +def short_public_id() -> str: + return "site_" + secrets.token_urlsafe(12)[:16] + + +class Site(models.Model): + name = models.CharField(max_length=100) + site_id = models.CharField(max_length=32, unique=True, default=short_public_id) + domains = models.JSONField(default=list) # 域名白名单 ["www.example.com"] + allow_no_origin = models.BooleanField(default=False) # 本地/demo 调试用 + is_active = models.BooleanField(default=True) + timezone = models.CharField(max_length=64, default="Asia/Shanghai") + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + + class Meta: + db_table = "site" + + +class Visitor(models.Model): + site = models.ForeignKey(Site, on_delete=models.CASCADE, related_name="visitors") + visitor_id = models.CharField(max_length=64) + first_seen_at = models.DateTimeField(auto_now_add=True) + last_seen_at = models.DateTimeField(auto_now_add=True) + first_referrer_domain = models.CharField(max_length=200, blank=True, default="") + first_utm = models.JSONField(default=dict, blank=True) + first_landing_path = models.CharField(max_length=500, blank=True, default="") + device_type = models.CharField(max_length=16, blank=True, default="") + os = models.CharField(max_length=64, blank=True, default="") + browser = models.CharField(max_length=64, blank=True, default="") + language = models.CharField(max_length=32, blank=True, default="") + timezone_name = models.CharField(max_length=64, blank=True, default="") + screen = models.CharField(max_length=32, blank=True, default="") + last_ip = models.GenericIPAddressField(null=True, blank=True) + country = models.CharField(max_length=64, blank=True, default="") + province = models.CharField(max_length=64, blank=True, default="") + city = models.CharField(max_length=64, blank=True, default="") + visit_count = models.PositiveIntegerField(default=0) # 会话数(服务端按新会话递增) + active_days = models.PositiveIntegerField(default=0) # 活跃天数 + last_active_date = models.DateField(null=True, blank=True) + is_bot = models.BooleanField(default=False) # UA 爬虫标记,报表默认排除 + + class Meta: + db_table = "visitor" + constraints = [models.UniqueConstraint(fields=["site", "visitor_id"], name="uq_site_visitor")] + indexes = [ + models.Index(fields=["site", "-last_seen_at"]), + models.Index(fields=["site", "country"]), + ] + + +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") + session_id = models.CharField(max_length=64) + # null=True:会话可先建行后回填时间(ended_at 为空表示会话进行中) + started_at = models.DateTimeField(null=True, blank=True) + ended_at = models.DateTimeField(null=True, blank=True) + referrer_domain = models.CharField(max_length=200, blank=True, default="") + utm = models.JSONField(default=dict, blank=True) + landing_path = models.CharField(max_length=500, blank=True, default="") + event_count = models.PositiveIntegerField(default=0) + duration_ms = models.PositiveBigIntegerField(default=0) # 会话内事件时间跨度 + stay_ms = models.PositiveBigIntegerField(default=0) # 有效停留累计(page_stay active_ms 之和) + + class Meta: + db_table = "visit_session" + constraints = [models.UniqueConstraint(fields=["site", "session_id"], name="uq_site_session")] + indexes = [models.Index(fields=["visitor", "-started_at"])] + + +class Event(models.Model): + site = models.ForeignKey(Site, on_delete=models.CASCADE, related_name="events") + visitor = models.ForeignKey(Visitor, on_delete=models.CASCADE, related_name="events") + session = models.ForeignKey(VisitSession, on_delete=models.CASCADE, related_name="events", null=True) + event_id = models.CharField(max_length=64) + event_name = models.CharField(max_length=50) + occurred_at = models.DateTimeField() + received_at = models.DateTimeField(auto_now_add=True) + path = models.CharField(max_length=500, blank=True, default="") + title = models.CharField(max_length=200, blank=True, default="") + properties = models.JSONField(default=dict, blank=True) + ip = models.GenericIPAddressField(null=True, blank=True) + country = models.CharField(max_length=64, blank=True, default="") + province = models.CharField(max_length=64, blank=True, default="") + city = models.CharField(max_length=64, blank=True, default="") + late = models.BooleanField(default=False) + is_bot = models.BooleanField(default=False) + + class Meta: + db_table = "event" + constraints = [models.UniqueConstraint(fields=["site", "event_id"], name="uq_site_event")] + indexes = [ + models.Index(fields=["site", "visitor", "-occurred_at"]), + models.Index(fields=["session", "occurred_at"]), + models.Index(fields=["site", "event_name", "-occurred_at"]), + ] + + +class DailyStat(models.Model): + site = models.ForeignKey(Site, on_delete=models.CASCADE, related_name="daily_stats") + date = models.DateField() + pv = models.PositiveIntegerField(default=0) + uv = models.PositiveIntegerField(default=0) + sessions = models.PositiveIntegerField(default=0) + new_visitors = models.PositiveIntegerField(default=0) + + class Meta: + db_table = "daily_stat" + constraints = [models.UniqueConstraint(fields=["site", "date"], name="uq_site_date")] diff --git a/server/tracker/tests/__init__.py b/server/tracker/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/server/tracker/tests/test_models.py b/server/tracker/tests/test_models.py new file mode 100644 index 0000000..601dc81 --- /dev/null +++ b/server/tracker/tests/test_models.py @@ -0,0 +1,26 @@ +import pytest +from tracker.models import Site, Visitor, VisitSession, Event + +pytestmark = pytest.mark.django_db + +def test_create_site_generates_public_site_id(): + site = Site.objects.create(name="官网", domains=["www.example.com"]) + assert site.site_id.startswith("site_") and len(site.site_id) == 21 + assert site.is_active is True + +def test_visitor_first_touch_fields(): + site = Site.objects.create(name="s", domains=["a.com"]) + v = Visitor.objects.create(site=site, visitor_id="vid1", + first_referrer_domain="google.com", first_utm={"utm_source": "google"}, + first_landing_path="/p", country="美国", device_type="PC") + assert v.visit_count == 0 and v.active_days == 0 + +def test_event_unique_site_event_id(): + site = Site.objects.create(name="s", domains=["a.com"]) + v = Visitor.objects.create(site=site, visitor_id="vid1") + s = VisitSession.objects.create(site=site, visitor=v, session_id="sid1") + 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): + Event.objects.create(site=site, visitor=v, session=s, event_id="e1", + event_name="page_view", occurred_at="2026-09-15T10:01:00Z", path="/")