feat(server): schema v2契约——session_resume白名单/Event归属与区间字段/会话resume_of/request_id
Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
@@ -1,6 +1,7 @@
|
|||||||
import re
|
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_EVENTS_PER_BATCH = 50
|
||||||
MAX_PROP_KEYS = 32
|
MAX_PROP_KEYS = 32
|
||||||
MAX_STR_LEN = 500
|
MAX_STR_LEN = 500
|
||||||
@@ -21,8 +22,9 @@ SESSION_IDLE_MS = 30 * 60 * 1000
|
|||||||
BOT_UA_KEYWORDS = ("bot", "spider", "crawl", "slurp", "bingpreview", "headless", "lighthouse")
|
BOT_UA_KEYWORDS = ("bot", "spider", "crawl", "slurp", "bingpreview", "headless", "lighthouse")
|
||||||
EVENT_PROP_WHITELIST = { # spec §9.1:未知字段拒绝。免费文本/任意属性不得入库
|
EVENT_PROP_WHITELIST = { # spec §9.1:未知字段拒绝。免费文本/任意属性不得入库
|
||||||
"page_view": {"referrer", "referrer_domain", "utm", "nav_type", "prerendering"},
|
"page_view": {"referrer", "referrer_domain", "utm", "nav_type", "prerendering"},
|
||||||
"page_stay": {"active_ms", "path"},
|
"page_stay": {"active_ms", "path", "active_from", "active_to", "flush_reason", "measurement_version"},
|
||||||
"sdk_loaded": {"sdk_version"},
|
"sdk_loaded": {"sdk_version", "build_id", "schema_version", "config_version"},
|
||||||
|
"session_resume": {"prev_session_id", "reason"},
|
||||||
}
|
}
|
||||||
MAX_PROP_DEPTH = 2
|
MAX_PROP_DEPTH = 2
|
||||||
MAX_PROP_JSON_BYTES = 4096
|
MAX_PROP_JSON_BYTES = 4096
|
||||||
|
|||||||
@@ -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'),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -69,6 +69,8 @@ class VisitSession(models.Model):
|
|||||||
event_count = models.PositiveIntegerField(default=0)
|
event_count = models.PositiveIntegerField(default=0)
|
||||||
duration_ms = models.PositiveBigIntegerField(default=0) # 会话内事件时间跨度
|
duration_ms = models.PositiveBigIntegerField(default=0) # 会话内事件时间跨度
|
||||||
stay_ms = models.PositiveBigIntegerField(default=0) # 有效停留累计(page_stay active_ms 之和)
|
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:
|
class Meta:
|
||||||
db_table = "visit_session"
|
db_table = "visit_session"
|
||||||
@@ -99,6 +101,14 @@ class Event(models.Model):
|
|||||||
city = models.CharField(max_length=64, blank=True, default="")
|
city = models.CharField(max_length=64, blank=True, default="")
|
||||||
late = models.BooleanField(default=False)
|
late = models.BooleanField(default=False)
|
||||||
is_bot = 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:
|
class Meta:
|
||||||
db_table = "event"
|
db_table = "event"
|
||||||
@@ -128,6 +138,7 @@ class RawBatch(models.Model):
|
|||||||
status = models.CharField(max_length=16, default="accepted") # accepted/partial/rejected
|
status = models.CharField(max_length=16, default="accepted") # accepted/partial/rejected
|
||||||
received_at = models.DateTimeField(auto_now_add=True)
|
received_at = models.DateTimeField(auto_now_add=True)
|
||||||
ip = models.GenericIPAddressField(null=True, blank=True)
|
ip = models.GenericIPAddressField(null=True, blank=True)
|
||||||
|
request_id = models.CharField(max_length=64, blank=True, default="") # schema v2:批级请求标识(对账/去重排查)
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
db_table = "raw_batch"
|
db_table = "raw_batch"
|
||||||
|
|||||||
@@ -4,6 +4,16 @@ from tracker.models import Site, Visitor, VisitSession, Event
|
|||||||
|
|
||||||
pytestmark = pytest.mark.django_db
|
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():
|
def test_create_site_generates_public_site_id():
|
||||||
site = Site.objects.create(name="官网", domains=["www.example.com"])
|
site = Site.objects.create(name="官网", domains=["www.example.com"])
|
||||||
assert site.site_id.startswith("site_") and len(site.site_id) == 21
|
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="/")
|
event_name="page_view", occurred_at="2026-09-15T10:00:00Z", path="/")
|
||||||
site.delete()
|
site.delete()
|
||||||
assert Visitor.objects.count() == 0 and VisitSession.objects.count() == 0 and Event.objects.count() == 0
|
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
|
||||||
|
|||||||
Reference in New Issue
Block a user