feat(server): ingest v2事件级归属+session_resume+stay区间并集重算+响应明细契约

Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
浅念
2026-09-18 11:14:06 +08:00
parent d8999035c9
commit 8459e6fa13
5 changed files with 321 additions and 31 deletions
+29 -1
View File
@@ -24,7 +24,11 @@ def _body(n=1, sid="s1", vid="v1"):
def test_collect_ok_creates_events(db, client):
Site.objects.create(name="s", domains=["www.a.com"], site_id="site_x")
r = _post(client, _body(2))
assert r.status_code == 200 and r.json() == {"code": 200, "data": {"accepted": 2, "duplicated": 0}, "message": "ok"}
assert r.status_code == 200
body = r.json() # 包含式断言:v2 响应新增 rejected/request_id/server_time 字段
assert body["code"] == 200 and body["message"] == "ok"
assert body["data"]["accepted"] == 2 and body["data"]["duplicated"] == 0
assert body["data"]["rejected"] == [] and body["data"]["request_id"]
assert r["Access-Control-Allow-Origin"] == "https://www.a.com"
def test_origin_not_whitelisted_rejected(db, client):
@@ -97,3 +101,27 @@ def test_malformed_content_length_400_not_500(db, client):
Site.objects.create(name="s", domains=["www.a.com"], site_id="site_x")
r = _post(client, _body(1), extra={"CONTENT_LENGTH": "abc"})
assert r.status_code == 400 and r.json()["message"] == "payload_too_large"
# ---- 响应契约(阶段A Task3)----
def test_response_contract_rejected_details(db, client):
"""部分拒绝批:rejected 明细(index/event_id/reason)回传——HTTP 200 不再隐含全量成功。"""
Site.objects.create(name="s", domains=["www.a.com"], site_id="site_x")
b = _body(2)
b["events"][0]["event_name"] = "bad_name"
r = _post(client, b)
assert r.status_code == 200
data = r.json()["data"]
assert data["accepted"] == 1 and data["duplicated"] == 0
assert data["rejected"] == [{"index": 0, "event_id": "e0",
"reason": "event_name_not_allowed"}]
def test_request_id_in_response_and_raw_batch(db, client):
"""request_id(uuid4 hex 32)随响应回传,且与 RawBatch.request_id 同值落库(对账)。"""
from tracker.models import RawBatch
Site.objects.create(name="s", domains=["www.a.com"], site_id="site_x")
r = _post(client, _body(1))
rid = r.json()["data"]["request_id"]
assert isinstance(rid, str) and len(rid) == 32
assert RawBatch.objects.get().request_id == rid
+133
View File
@@ -217,3 +217,136 @@ def test_device_model_backfilled_on_later_batch(db):
ingest_batch(site, b3)
v.refresh_from_db()
assert v.device_model == "Pixel 7" # 已有值不再覆盖
# ---- schema v2(阶段A Task3:事件级归属 / session_resume / 区间并集 / 时间质量)----
SITE_ID = "site_v2"
def _now_iso():
return timezone.now().isoformat()
def _v2_event(name, page_id, sid, seq=1, **props):
return {"event_id": f"e-{name}-{seq}", "event_name": name,
"occurred_at": _now_iso(), "path": "/p", "title": "t",
"site_id": SITE_ID, "visitor_id": "v1", "session_id": sid,
"page_id": page_id, "seq": seq, "properties": props}
def _v2_batch(events, vid="v1", sid="s1"):
return {"schema_version": 2, "site_id": SITE_ID, "visitor_id": vid,
"session_id": sid, "sdk_version": "0.2.0",
"context": {"referrer_domain": "", "path": "/", "device_type": "PC"},
"events": events}
# 20. v2 事件级归属:同批多 session 各自落库(v1 信封 session_id 不再是唯一依据);
# 事件级 site/visitor 与批级不一致 → attribution_mismatch
def test_v2_event_level_attribution(db):
site = Site.objects.create(name="s", domains=["a.com"], site_id=SITE_ID)
evs = [_v2_event("page_view", "p1", "sA", seq=1),
_v2_event("page_view", "p2", "sB", seq=2)]
r = ingest_batch(site, _v2_batch(evs, sid="sA"))
assert (r["accepted"], r["duplicated"], r["rejected"]) == (2, 0, [])
assert VisitSession.objects.count() == 2
for sid_name, pid in (("sA", "p1"), ("sB", "p2")):
sess = VisitSession.objects.get(session_id=sid_name)
e = Event.objects.get(session=sess)
assert e.page_id == pid and e.schema_version == 2
bad_v = _v2_event("page_view", "p3", "sA", seq=3)
bad_v["visitor_id"] = "v-other" # 事件级 visitor ≠ 批级
bad_s = _v2_event("page_view", "p4", "sA", seq=4)
bad_s["site_id"] = "site_other" # 事件级 site ≠ 批级
r2 = ingest_batch(site, _v2_batch(
[bad_v, bad_s, _v2_event("page_view", "p5", "sA", seq=5)], sid="sA"))
assert r2["accepted"] == 1
assert r2["rejected"] == [
{"index": 0, "event_id": "e-page_view-3", "reason": "attribution_mismatch"},
{"index": 1, "event_id": "e-page_view-4", "reason": "attribution_mismatch"}]
# 21. session_resume:新会话 resume_of 链接 prev_session_id 对应会话;
# prev 不存在软处理(resume_of=None 不拒);v2 不做 30min 兜底切分
def test_session_resume_creates_linked_session(db):
site = Site.objects.create(name="s", domains=["a.com"], site_id=SITE_ID)
ingest_batch(site, _v2_batch([_v2_event("page_view", "p1", "s1", seq=1)], sid="s1"))
r = ingest_batch(site, _v2_batch([
_v2_event("session_resume", "", "s2", seq=2, prev_session_id="s1", reason="idle_timeout"),
_v2_event("page_view", "p2", "s2", seq=3)], sid="s2"))
assert (r["accepted"], r["rejected"]) == (2, [])
s1 = VisitSession.objects.get(session_id="s1")
s2 = VisitSession.objects.get(session_id="s2")
assert s2.resume_of == s1
r2 = ingest_batch(site, _v2_batch([
_v2_event("session_resume", "", "s3", seq=4, prev_session_id="ghost", reason="idle_timeout"),
_v2_event("page_view", "p3", "s3", seq=5)], sid="s3"))
assert (r2["accepted"], r2["rejected"]) == (2, []) # prev 无效不拒
assert VisitSession.objects.get(session_id="s3").resume_of is None
assert Visitor.objects.get(visitor_id="v1").visit_count == 3 # s1/s2/s3
# 35min 后同 sid 再发:不切 s2#2(v2 会话切换由 SDK 显式 resume 驱动)
late_ev = _v2_event("page_view", "p2b", "s2", seq=6)
late_ev["occurred_at"] = (timezone.now() + timedelta(minutes=35)).isoformat()
r3 = ingest_batch(site, _v2_batch([late_ev], sid="s2"))
assert r3["accepted"] == 1
assert VisitSession.objects.filter(session_id__startswith="s2").count() == 1
# 22. v2 page_stay 区间并集(非加法):[0,15][15,30][40,55]s → 0-30 ∪ 40-55 = 45s
def test_stay_interval_union_not_sum(db):
site = Site.objects.create(name="s", domains=["a.com"], site_id=SITE_ID)
secs = [(0, 15), (15, 30), (40, 55)]
evs = [_v2_event("page_stay", "p1", "s1", seq=i + 1,
active_from=a * 1000, active_to=b * 1000, flush_reason="periodic")
for i, (a, b) in enumerate(secs)]
r = ingest_batch(site, _v2_batch(evs, sid="s1"))
assert (r["accepted"], r["rejected"]) == (3, [])
e1 = Event.objects.get(event_id="e-page_stay-1")
assert (e1.active_from_ms, e1.active_to_ms) == (0, 15000)
assert e1.flush_reason == "periodic" and e1.sdk_version == "0.2.0"
assert VisitSession.objects.get(session_id="s1").stay_ms == 45000 # 非 sum=60000
# 23. v1 路径完全不变:stay_ms 仍为 active_ms 加法,v2 新字段留空
def test_v1_events_unchanged_path(db):
site = Site.objects.create(name="s", domains=["a.com"])
ingest_batch(site, _batch([_ev(1), _ev(2, "page_stay", properties={"active_ms": 5000, "path": "/p"})]))
ingest_batch(site, _batch([_ev(3, "page_stay", properties={"active_ms": 3000, "path": "/p"})]))
sess = VisitSession.objects.get()
assert sess.stay_ms == 8000 and sess.event_count == 3
e = Event.objects.get(event_id="e1")
assert e.schema_version is None and e.page_id == "" and e.time_quality == ""
assert e.active_from_ms is None and e.active_to_ms is None
# 24. time_quality 分类:正常 ok / >120s 过去 late / 未来小偏移 ok / 未来超阈 skew_backwards
def test_time_quality_classification(db):
site = Site.objects.create(name="s", domains=["a.com"], site_id=SITE_ID)
cases = [(1, 0, "ok"), (2, -130, "late"), (3, 5, "ok"), (4, 130, "skew_backwards")]
evs = []
for seq, off, _ in cases:
e = _v2_event("page_view", f"p{seq}", "s1", seq=seq)
e["occurred_at"] = (timezone.now() + timedelta(seconds=off)).isoformat()
evs.append(e)
r = ingest_batch(site, _v2_batch(evs, sid="s1"))
assert (r["accepted"], r["rejected"]) == (4, [])
for seq, off, want in cases:
e = Event.objects.get(event_id=f"e-page_view-{seq}")
assert e.time_quality == want, f"seq={seq} offset={off}s"
assert e.late == (abs(off) > 120) # late 布尔语义不动
# 25. 混合解释:同会话 v1 active_ms 加法 + v2 区间并集 相加(单一解释)
def test_recompute_mixed_v1_v2(db):
site = Site.objects.create(name="s", domains=["a.com"], site_id=SITE_ID)
ingest_batch(site, _batch(
[_ev(1), _ev(2, "page_stay", properties={"active_ms": 10000, "path": "/p"})], sid="sm"))
evs = [_v2_event("page_stay", "px", "sm", seq=1, active_from=0, active_to=10000, flush_reason="periodic"),
_v2_event("page_stay", "py", "sm", seq=2, active_from=0, active_to=5000, flush_reason="periodic")]
r = ingest_batch(site, _v2_batch(evs, sid="sm"))
assert (r["accepted"], r["rejected"]) == (2, [])
assert VisitSession.objects.get(session_id="sm").stay_ms == 10000 + 15000 # v1 SUM + v2 并集
# 26. 脏区间防御:非数值/倒置区间不参与并集、不拒批(防 stay_ms 负值炸 PositiveBigInteger)
def test_dirty_intervals_soft_skipped(db):
site = Site.objects.create(name="s", domains=["a.com"], site_id=SITE_ID)
evs = [_v2_event("page_stay", "p1", "s1", seq=1, active_from=0, active_to=10000, flush_reason="periodic"),
_v2_event("page_stay", "p2", "s1", seq=2, active_from="x", active_to=9000, flush_reason="periodic"),
_v2_event("page_stay", "p3", "s1", seq=3, active_from=20000, active_to=10000, flush_reason="periodic")]
r = ingest_batch(site, _v2_batch(evs, sid="s1"))
assert (r["accepted"], r["rejected"]) == (3, []) # 脏数据软处理不拒批
e2 = Event.objects.get(event_id="e-page_stay-2")
assert e2.active_from_ms is None # 非数值 int 化为 None
assert VisitSession.objects.get(session_id="s1").stay_ms == 10000 # 仅 p1 有效区间