From d8999035c970d0dfa9bfe4dfc1831f945c3b8218 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=B5=85=E5=BF=B5?= Date: Fri, 18 Sep 2026 10:38:20 +0800 Subject: [PATCH] =?UTF-8?q?feat(server):=20schema=20v2=E5=A5=91=E7=BA=A6?= =?UTF-8?q?=E2=80=94=E2=80=94session=5Fresume=E7=99=BD=E5=90=8D=E5=8D=95/E?= =?UTF-8?q?vent=E5=BD=92=E5=B1=9E=E4=B8=8E=E5=8C=BA=E9=97=B4=E5=AD=97?= =?UTF-8?q?=E6=AE=B5/=E4=BC=9A=E8=AF=9Dresume=5Fof/request=5Fid?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Code --- server/tracker/constants.py | 8 ++- ...ive_from_ms_event_active_to_ms_and_more.py | 59 +++++++++++++++++++ server/tracker/models.py | 11 ++++ server/tracker/tests/test_models.py | 21 +++++++ 4 files changed, 96 insertions(+), 3 deletions(-) create mode 100644 server/tracker/migrations/0006_event_active_from_ms_event_active_to_ms_and_more.py diff --git a/server/tracker/constants.py b/server/tracker/constants.py index 7794f17..1794098 100644 --- a/server/tracker/constants.py +++ b/server/tracker/constants.py @@ -1,6 +1,7 @@ import re -EVENT_NAMES = {"page_view", "page_stay", "sdk_loaded"} +EVENT_NAMES = {"page_view", "page_stay", "sdk_loaded", "session_resume"} +SCHEMA_VERSION = 2 # 批级信缺省 = v1(旧SDK) MAX_EVENTS_PER_BATCH = 50 MAX_PROP_KEYS = 32 MAX_STR_LEN = 500 @@ -21,8 +22,9 @@ SESSION_IDLE_MS = 30 * 60 * 1000 BOT_UA_KEYWORDS = ("bot", "spider", "crawl", "slurp", "bingpreview", "headless", "lighthouse") EVENT_PROP_WHITELIST = { # spec §9.1:未知字段拒绝。免费文本/任意属性不得入库 "page_view": {"referrer", "referrer_domain", "utm", "nav_type", "prerendering"}, - "page_stay": {"active_ms", "path"}, - "sdk_loaded": {"sdk_version"}, + "page_stay": {"active_ms", "path", "active_from", "active_to", "flush_reason", "measurement_version"}, + "sdk_loaded": {"sdk_version", "build_id", "schema_version", "config_version"}, + "session_resume": {"prev_session_id", "reason"}, } MAX_PROP_DEPTH = 2 MAX_PROP_JSON_BYTES = 4096 diff --git a/server/tracker/migrations/0006_event_active_from_ms_event_active_to_ms_and_more.py b/server/tracker/migrations/0006_event_active_from_ms_event_active_to_ms_and_more.py new file mode 100644 index 0000000..23ac8df --- /dev/null +++ b/server/tracker/migrations/0006_event_active_from_ms_event_active_to_ms_and_more.py @@ -0,0 +1,59 @@ +# Generated by Django 5.2.17 on 2026-09-18 02:36 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('tracker', '0005_visitor_device_model_visitor_env_extra'), + ] + + operations = [ + migrations.AddField( + model_name='event', + name='active_from_ms', + field=models.BigIntegerField(blank=True, null=True), + ), + migrations.AddField( + model_name='event', + name='active_to_ms', + field=models.BigIntegerField(blank=True, null=True), + ), + migrations.AddField( + model_name='event', + name='flush_reason', + field=models.CharField(blank=True, default='', max_length=16), + ), + migrations.AddField( + model_name='event', + name='page_id', + field=models.CharField(blank=True, db_index=True, default='', max_length=64), + ), + migrations.AddField( + model_name='event', + name='schema_version', + field=models.PositiveSmallIntegerField(blank=True, null=True), + ), + migrations.AddField( + model_name='event', + name='sdk_version', + field=models.CharField(blank=True, default='', max_length=16), + ), + migrations.AddField( + model_name='event', + name='time_quality', + field=models.CharField(blank=True, default='', max_length=16), + ), + migrations.AddField( + model_name='rawbatch', + name='request_id', + field=models.CharField(blank=True, default='', max_length=64), + ), + migrations.AddField( + model_name='visitsession', + name='resume_of', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='resumes', to='tracker.visitsession'), + ), + ] diff --git a/server/tracker/models.py b/server/tracker/models.py index a9a78db..7e39f2a 100644 --- a/server/tracker/models.py +++ b/server/tracker/models.py @@ -69,6 +69,8 @@ class VisitSession(models.Model): event_count = models.PositiveIntegerField(default=0) duration_ms = models.PositiveBigIntegerField(default=0) # 会话内事件时间跨度 stay_ms = models.PositiveBigIntegerField(default=0) # 有效停留累计(page_stay active_ms 之和) + resume_of = models.ForeignKey("self", null=True, blank=True, on_delete=models.SET_NULL, + related_name="resumes") # schema v2:session_resume 续接的前一会话(被指向会话删除后置 NULL 保留链) class Meta: db_table = "visit_session" @@ -99,6 +101,14 @@ class Event(models.Model): city = models.CharField(max_length=64, blank=True, default="") late = models.BooleanField(default=False) is_bot = models.BooleanField(default=False) + # ---- schema v2(阶段A):旧行空 = legacy ---- + page_id = models.CharField(max_length=64, blank=True, default="", db_index=True) # 埋点实例归属(spa 路由切换每页新 id) + active_from_ms = models.BigIntegerField(null=True, blank=True) # 活跃区间起(页面时间戳 ms) + active_to_ms = models.BigIntegerField(null=True, blank=True) # 活跃区间止 + flush_reason = models.CharField(max_length=16, blank=True, default="") # periodic/hidden/unload/interval等 + time_quality = models.CharField(max_length=16, blank=True, default="") # ok/adjusted/monotonic…(late 的补充细化) + sdk_version = models.CharField(max_length=16, blank=True, default="") + schema_version = models.PositiveSmallIntegerField(null=True, blank=True) # 事件级信(缺省为批级/1) class Meta: db_table = "event" @@ -128,6 +138,7 @@ class RawBatch(models.Model): status = models.CharField(max_length=16, default="accepted") # accepted/partial/rejected received_at = models.DateTimeField(auto_now_add=True) ip = models.GenericIPAddressField(null=True, blank=True) + request_id = models.CharField(max_length=64, blank=True, default="") # schema v2:批级请求标识(对账/去重排查) class Meta: db_table = "raw_batch" diff --git a/server/tracker/tests/test_models.py b/server/tracker/tests/test_models.py index 205bd8f..f138644 100644 --- a/server/tracker/tests/test_models.py +++ b/server/tracker/tests/test_models.py @@ -4,6 +4,16 @@ from tracker.models import Site, Visitor, VisitSession, Event pytestmark = pytest.mark.django_db + +def _base_event_kwargs(): + """合法 Event 基线 kwargs(schema v2 测试用)""" + site = Site.objects.create(name="s", domains=["a.com"]) + visitor = Visitor.objects.create(site=site, visitor_id="vid1") + session = VisitSession.objects.create(site=site, visitor=visitor, session_id="sid1", + started_at="2026-09-15T10:00:00Z", ended_at="2026-09-15T10:01:00Z") + return dict(site=site, visitor=visitor, session=session, event_id="e1", + event_name="page_stay", occurred_at="2026-09-15T10:00:30Z", path="/") + 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 @@ -36,3 +46,14 @@ def test_site_cascade_deletes_children(): 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 + + +def test_event_has_phase_a_fields(): + e = Event(**_base_event_kwargs(), page_id="p1", active_from_ms=0, + active_to_ms=15000, flush_reason="periodic", + time_quality="ok", sdk_version="0.2.0", schema_version=2) + e.full_clean() + + +def test_visit_session_resume_of_self_fk(): + assert VisitSession._meta.get_field("resume_of").related_model is VisitSession