feat: 移动端设备信息最大化采集(型号/UA-CH/视口/方向/触点/内存/网络/深浅色)

SDK:buildEnvContext 同步扩展 12 键(dpr/viewport/orientation/maxTouchPoints/
deviceMemory/languages/colorScheme/standalone/network downlink·rtt·saveData/
微信版本),逐键 try/catch 静默缺则不挂;新增 collectHighEntropyHints(UA-CH
model/platformVersion/architecture/bitness),init 后 Promise.race 2s 超时
异步并入 __ctk_ctx——批次 context flush 时读取,不阻塞首屏。

后端:Visitor 加 device_model/env_extra(迁移 0005);ingest 首批快照
EXTENDED_ENV_KEYS 白名单(值截 100),非首批不覆盖、唯 device_model 空且
本批有值时补填一次(晚到 hints);列表/详情 API 随行输出。

前端:详情访问环境卡设备行升级显示型号(手机 · Pixel 7),追加像素比/
视口/屏幕方向/触点/内存/网络质量/深浅色/微信版本行(env_extra 逐键判空,
旧数据整块不显示);列表设备列主行拼型号(手机·Pixel 7)。

Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
浅念
2026-09-16 19:12:20 +08:00
parent 7f2a746f66
commit 22d89a3f42
14 changed files with 484 additions and 14 deletions
+55
View File
@@ -162,3 +162,58 @@ def test_first_touch_classification_uses_full_pageview_referrer(db):
ft = SourceClassification.objects.get(scope="first_touch")
assert ft.channel == "natural_search" and ft.platform == "百度"
assert ft.search_term == "挖掘机" and ft.resolution == "resolved"
# 17. 扩展环境键快照:device_model 独立列 + env_extra 白名单(白名单外不入库)
def test_extended_env_snapshot_persisted(db):
site = Site.objects.create(name="s", domains=["a.com"])
b = _batch([_ev(1)])
b["context"].update({
"device_model": "Pixel 7", "device_pixel_ratio": 2.625, "viewport": "393x851",
"orientation": "portrait-primary", "max_touch_points": 5, "device_memory": 8,
"languages": "zh-CN,en", "color_scheme": "dark", "standalone": "0",
"network_downlink": 10, "network_rtt": 50, "save_data": "0",
"wechat_version": "8.0.49", "platform_version": "13.0.0",
"architecture": "arm", "bitness": "64",
"evil_key": "<script>", # 白名单外:不入 env_extra
})
ingest_batch(site, b)
v = Visitor.objects.get(visitor_id="v1")
assert v.device_model == "Pixel 7"
assert v.env_extra == {
"device_model": "Pixel 7", "device_pixel_ratio": "2.625", "viewport": "393x851",
"orientation": "portrait-primary", "max_touch_points": "5", "device_memory": "8",
"languages": "zh-CN,en", "color_scheme": "dark", "standalone": "0",
"network_downlink": "10", "network_rtt": "50", "save_data": "0",
"wechat_version": "8.0.49", "platform_version": "13.0.0",
"architecture": "arm", "bitness": "64"}
# 18. 扩展字段截断:device_model 64 / env_extra 值 100
def test_extended_env_fields_truncated(db):
site = Site.objects.create(name="s", domains=["a.com"])
b = _batch([_ev(1)])
b["context"].update({"device_model": "M" * 80, "languages": "x" * 200})
ingest_batch(site, b)
v = Visitor.objects.get(visitor_id="v1")
assert v.device_model == "M" * 64 # 独立列按 CharField(64) 截断
assert v.env_extra["device_model"] == "M" * 80 # env_extra 白名单统一 [:100](80<100 原样)
assert v.env_extra["languages"] == "x" * 100
# 19. 晚到 UA-CH hints:首批无型号 → 第二批只补填 device_model 列,env_extra 保持首触快照
def test_device_model_backfilled_on_later_batch(db):
site = Site.objects.create(name="s", domains=["a.com"])
ingest_batch(site, _batch([_ev(1)])) # 首批无任何扩展键
v = Visitor.objects.get(visitor_id="v1")
assert v.device_model == "" and v.env_extra == {}
b = _batch([_ev(2)])
b["context"].update({"device_model": "Pixel 7", "viewport": "393x851"})
ingest_batch(site, b)
v.refresh_from_db()
assert v.device_model == "Pixel 7" # 空列补填一次
assert v.env_extra == {} # env_extra 不补写(首触快照语义)
b3 = _batch([_ev(3)])
b3["context"]["device_model"] = "iPhone 15 Pro"
ingest_batch(site, b3)
v.refresh_from_db()
assert v.device_model == "Pixel 7" # 已有值不再覆盖
+25 -1
View File
@@ -78,7 +78,8 @@ def test_list_fields_and_default_bot_excluded(client, member_user):
"device_type", "os", "browser", "language", "first_seen_at",
"last_seen_at", "visit_count", "active_days", "session_count",
"total_stay_ms", "first_source", "last_source", "first_landing_path",
"online", "channel", "platform", "variant", "search_term", "resolution"}
"online", "channel", "platform", "variant", "search_term", "resolution",
"device_model"}
assert row["country"] == "中国" and row["os"] == "Windows" and row["browser"] == "Chrome"
assert row["session_count"] == 2
assert row["total_stay_ms"] == 150000 # 120000+30000,有效停留口径
@@ -429,3 +430,26 @@ def test_detail_source_evidence_no_classification(client, member_user):
assert attr["first_observable"] == {"channel": "unknown", "resolution": "unknown"}
assert attr["first_identifiable"] is None
assert attr["latest_session"] is None
def test_device_model_and_env_extra_fields(client, member_user):
"""设备型号/扩展环境快照:新数据随行输出;旧访客空串/空对象(前端逐键判空跳行)。"""
_auth(client, member_user)
site = Site.objects.create(name="s", domains=["a.com"])
_visitor(site, "v-new", last_seen=timezone.now(), device_model="Pixel 7",
env_extra={"viewport": "393x851", "device_pixel_ratio": "2.625",
"color_scheme": "dark", "network_downlink": "10",
"network_rtt": "50", "save_data": "0", "wechat_version": "8.0.49"})
_visitor(site, "v-old", last_seen=timezone.now() - timedelta(hours=1))
d = client.get("/api/admin/visitors").json()["data"]
by_id = {x["visitor_id"]: x for x in d["list"]}
assert by_id["v-new"]["device_model"] == "Pixel 7" # 列表行随带(设备列小字用)
assert by_id["v-old"]["device_model"] == ""
d = client.get("/api/admin/visitors/v-new").json()["data"]
assert d["device_model"] == "Pixel 7"
assert d["env_extra"]["viewport"] == "393x851"
assert d["env_extra"]["network_rtt"] == "50"
d = client.get("/api/admin/visitors/v-old").json()["data"]
assert d["device_model"] == "" and d["env_extra"] == {}