feat(admin): 时间线业务事件徽章+询盘漏斗卡+付费社交渠道标签(含server时间线安全字段)
Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
@@ -23,6 +23,34 @@ TIMELINE_MAX_EVENTS = 1000
|
||||
ONLINE_WINDOW = timedelta(minutes=5) # 与 dashboard active_now 同口径
|
||||
ATTR_SESSION_ENTRIES = 5 # 详情页归因块带出的最近会话入口分类数
|
||||
|
||||
# 业务事件时间线安全字段白名单(阶段B SDK 0.2.1 事件):按事件名枚举可透出的非 PII 字段
|
||||
# (OT10 白名单内),不在表内的事件/字段一律不透出 properties 原文——隐私边界与
|
||||
# _merge_page_stay 的既有白名单口径一致。submission_id 只透出前 8 位(对账够用)。
|
||||
TIMELINE_SAFE_FIELDS = {
|
||||
"contact_click": ("channel",),
|
||||
"form_start": ("form_id",),
|
||||
"form_submit": ("form_id", "submission_id"),
|
||||
"form_error": ("error_code", "field_count"),
|
||||
"form_submit_result": ("result_code",),
|
||||
"product_view": ("product_id", "inferred"),
|
||||
}
|
||||
|
||||
|
||||
def _safe_fields(e):
|
||||
"""事件行安全字段提取:白名单命中才输出,且只出白名单键(缺失键不占位);
|
||||
form_submit 的 submission_id 截前 8 位。"""
|
||||
keys = TIMELINE_SAFE_FIELDS.get(e.event_name)
|
||||
if not keys:
|
||||
return {}
|
||||
props = e.properties or {}
|
||||
out = {}
|
||||
for k in keys:
|
||||
v = props.get(k)
|
||||
if v is None:
|
||||
continue
|
||||
out[k] = str(v)[:8] if k == "submission_id" else v
|
||||
return out
|
||||
|
||||
|
||||
def _source(referrer_domain, utm):
|
||||
"""来源三级兜底:referrer_domain → utm.utm_source → 直接访问(访客档案/会话共用)。"""
|
||||
@@ -182,7 +210,8 @@ def _merge_page_stay(events):
|
||||
最近一条同 path 的 page_view 条目,把 active_ms 累加进去(同页多段隐藏/恢复多次累加);
|
||||
找不到匹配(含 page_stay 早于任何 page_view 出现)则独立成条目(event_name 保持 page_stay)。
|
||||
作用域天然限定在本组事件序列(单会话/时间线单会话桶)——跨会话绝不合并。
|
||||
只出 time/event_name/path/title/active_ms 白名单字段,不透出 properties 原文(隐私)。
|
||||
只出 time/event_name/path/title/active_ms(+业务事件 _safe_fields 白名单字段),
|
||||
不透出 properties 原文(隐私)。
|
||||
"""
|
||||
items = []
|
||||
for e in events:
|
||||
@@ -198,8 +227,10 @@ def _merge_page_stay(events):
|
||||
else:
|
||||
# page_view 初始 active_ms=0;其余(sdk_loaded/自定义事件)无停留概念 → null
|
||||
active = 0 if e.event_name == "page_view" else None
|
||||
# 业务事件附带 _safe_fields 白名单字段(其余 properties 键不透出)
|
||||
items.append({"time": _iso(e.occurred_at), "event_name": e.event_name,
|
||||
"path": e.path, "title": e.title, "active_ms": active})
|
||||
"path": e.path, "title": e.title, "active_ms": active,
|
||||
**_safe_fields(e)})
|
||||
return items
|
||||
|
||||
|
||||
|
||||
@@ -48,10 +48,12 @@ def _session(site, visitor, sid, at=NOW, referrer_domain="", utm=None,
|
||||
duration_ms=duration_ms, stay_ms=stay_ms)
|
||||
|
||||
|
||||
def _event(site, visitor, session, eid, name, at, path="", title="", active_ms=None):
|
||||
def _event(site, visitor, session, eid, name, at, path="", title="", active_ms=None,
|
||||
properties=None):
|
||||
return Event.objects.create(site=site, visitor=visitor, session=session,
|
||||
event_id=eid, event_name=name, occurred_at=at, path=path, title=title,
|
||||
properties={"active_ms": active_ms} if active_ms is not None else {})
|
||||
properties=properties if properties is not None else (
|
||||
{"active_ms": active_ms} if active_ms is not None else {}))
|
||||
|
||||
|
||||
def test_list_fields_and_default_bot_excluded(client, member_user):
|
||||
@@ -128,7 +130,9 @@ def test_list_filters_and_search(client, member_user):
|
||||
d = client.get("/api/admin/visitors", {"is_new": "1"}).json()["data"] # first_seen 在今天
|
||||
assert set(ids(d)) == {"cn-desktop", "us-mobile"}
|
||||
d = client.get("/api/admin/visitors", {"date_to": YESTERDAY.isoformat()}).json()["data"]
|
||||
assert ids(d) == ["old-visitor"] # last_seen 落在昨天
|
||||
# 断言 date_to 的确定边界:昨天者必收、今天者必排除。us-mobile 的 now-2h 在本地
|
||||
# 00:00-02:00 间跑测试会落到昨天(被一并收入)——属时钟归属,不作断言对象(防 flaky)。
|
||||
assert "old-visitor" in ids(d) and "cn-desktop" not in ids(d)
|
||||
|
||||
|
||||
def test_list_site_fields_and_site_filter(client, member_user):
|
||||
@@ -268,6 +272,57 @@ def test_timeline_day_grouped_and_stay_merge(client, member_user):
|
||||
assert y_evs[1]["title"] == "Pricing"
|
||||
|
||||
|
||||
def test_timeline_business_event_safe_fields(client, member_user):
|
||||
"""时间线业务事件安全字段白名单(阶段B T6 Step0):六类新事件行各带映射字段
|
||||
(form_submit 的 submission_id 只透前 8 位),其余 properties 键一律不泄漏
|
||||
(含故意塞进去的 PII 键);单会话接口走同一序列化,同样只出白名单。"""
|
||||
_auth(client, member_user)
|
||||
site = Site.objects.create(name="s", domains=["a.com"])
|
||||
v = _visitor(site, "v1")
|
||||
s = _session(site, v, "s1")
|
||||
base = NOW
|
||||
_event(site, v, s, "b1", "contact_click", base, path="/contact",
|
||||
properties={"channel": "whatsapp", "label": " WhatsApp 按钮", "href": "https://wa.me/8613800000000"})
|
||||
_event(site, v, s, "b2", "form_start", base + timedelta(seconds=1),
|
||||
properties={"form_id": "cf7-12", "page_url": "https://a.com/contact"})
|
||||
_event(site, v, s, "b3", "form_submit", base + timedelta(seconds=2),
|
||||
properties={"form_id": "cf7-12", "submission_id": "abcd1234-9999-zzzz",
|
||||
"fields": {"your-name": "张三", "your-email": "z@x.com"}})
|
||||
_event(site, v, s, "b4", "form_error", base + timedelta(seconds=3),
|
||||
properties={"error_code": "validation_failed", "field_count": 2,
|
||||
"messages": ["邮箱格式不对"]})
|
||||
_event(site, v, s, "b5", "form_submit_result", base + timedelta(seconds=4),
|
||||
properties={"result_code": "mail_sent", "smtp_detail": "250 ok"})
|
||||
_event(site, v, s, "b6", "product_view", base + timedelta(seconds=5),
|
||||
properties={"product_id": "excavator-x1", "inferred": True,
|
||||
"url": "https://a.com/product/excavator-x1"})
|
||||
|
||||
r = client.get("/api/admin/visitors/v1/timeline")
|
||||
assert r.status_code == 200
|
||||
evs = r.json()["data"]["days"][0]["sessions"][0]["events"]
|
||||
base_keys = {"time", "event_name", "path", "title", "active_ms"}
|
||||
e1, e2, e3, e4, e5, e6 = evs
|
||||
assert set(e1) == base_keys | {"channel"} and e1["channel"] == "whatsapp"
|
||||
assert set(e2) == base_keys | {"form_id"} and e2["form_id"] == "cf7-12"
|
||||
assert set(e3) == base_keys | {"form_id", "submission_id"}
|
||||
assert e3["submission_id"] == "abcd1234" # 前 8 位截断,尾部不透出
|
||||
assert "abcd1234-9999" not in r.content.decode() # 全量 submission_id 不出现在响应
|
||||
assert set(e4) == base_keys | {"error_code", "field_count"}
|
||||
assert e4["error_code"] == "validation_failed" and e4["field_count"] == 2
|
||||
assert set(e5) == base_keys | {"result_code"} and e5["result_code"] == "mail_sent"
|
||||
assert set(e6) == base_keys | {"product_id", "inferred"}
|
||||
assert e6["product_id"] == "excavator-x1" and e6["inferred"] is True
|
||||
body = r.content.decode()
|
||||
for leak in ("wa.me", "张三", "z@x.com", "邮箱格式不对", "250 ok", "smtp_detail",
|
||||
"page_url", "fields", "messages", "label"):
|
||||
assert leak not in body # 无其他 properties 键泄漏
|
||||
|
||||
d = client.get("/api/admin/visitors/v1/sessions/s1").json()["data"] # 同一序列化路径
|
||||
sub = next(e for e in d["events"] if e["event_name"] == "form_submit")
|
||||
assert sub["submission_id"] == "abcd1234"
|
||||
assert "fields" not in sub
|
||||
|
||||
|
||||
def test_timeline_days_param_and_404(client, member_user):
|
||||
_auth(client, member_user)
|
||||
site = Site.objects.create(name="s", domains=["a.com"])
|
||||
|
||||
Reference in New Issue
Block a user