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:
@@ -45,6 +45,7 @@ def _row(visitor, last_session, online_cutoff, ft=None):
|
||||
"country": visitor.country, "province": visitor.province, "city": visitor.city,
|
||||
"device_type": visitor.device_type, "os": visitor.os,
|
||||
"browser": visitor.browser, "language": visitor.language,
|
||||
"device_model": visitor.device_model, # UA-CH 型号(列表设备列小字/详情环境卡共用)
|
||||
"first_seen_at": _iso(visitor.first_seen_at),
|
||||
"last_seen_at": _iso(visitor.last_seen_at),
|
||||
"visit_count": visitor.visit_count, "active_days": visitor.active_days,
|
||||
@@ -258,6 +259,8 @@ class VisitorDetailView(EnvelopeMixin, APIView):
|
||||
"pages_viewed": Event.objects.filter(
|
||||
visitor=visitor, event_name="page_view").count(),
|
||||
"screen": visitor.screen, "timezone_name": visitor.timezone_name,
|
||||
# 扩展环境快照(EXTENDED_ENV_KEYS 白名单;旧数据为空对象,前端逐键判空)
|
||||
"env_extra": visitor.env_extra or {},
|
||||
"last_ip": visitor.last_ip, "first_utm": visitor.first_utm or {},
|
||||
"first_landing_path": visitor.first_landing_path,
|
||||
"first_referrer_domain": visitor.first_referrer_domain,
|
||||
|
||||
@@ -17,6 +17,12 @@ EVENT_PROP_WHITELIST = { # spec §9.1:未知字段拒绝。免费文
|
||||
}
|
||||
MAX_PROP_DEPTH = 2
|
||||
MAX_PROP_JSON_BYTES = 4096
|
||||
EXTENDED_ENV_KEYS = { # SDK 扩展环境键白名单(Visitor.env_extra 快照,展示用):
|
||||
"device_pixel_ratio", "viewport", "orientation", "max_touch_points", "device_memory", # 设备形态
|
||||
"languages", "color_scheme", "standalone", # 偏好/宿主
|
||||
"network_downlink", "network_rtt", "save_data", "wechat_version", # 网络/宿主
|
||||
"device_model", "platform_version", "architecture", "bitness", # UA-CH 高熵
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 来源归因规则库 v1(tracker/services/attribution.py 消费)。改规则必须 bump 版本号
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
# Generated by Django 5.2.17 on 2026-09-16 10:58
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('tracker', '0004_sourceclassification'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='visitor',
|
||||
name='device_model',
|
||||
field=models.CharField(blank=True, default='', max_length=64),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='visitor',
|
||||
name='env_extra',
|
||||
field=models.JSONField(blank=True, default=dict),
|
||||
),
|
||||
]
|
||||
@@ -36,6 +36,8 @@ class Visitor(models.Model):
|
||||
language = models.CharField(max_length=32, blank=True, default="")
|
||||
timezone_name = models.CharField(max_length=64, blank=True, default="")
|
||||
screen = models.CharField(max_length=32, blank=True, default="")
|
||||
device_model = models.CharField(max_length=64, blank=True, default="") # UA-CH model(如 Pixel 7;晚到 hints 允许补填一次)
|
||||
env_extra = models.JSONField(default=dict, blank=True) # 扩展环境快照(constants.EXTENDED_ENV_KEYS 白名单,首触快照)
|
||||
last_ip = models.GenericIPAddressField(null=True, blank=True)
|
||||
country = models.CharField(max_length=64, blank=True, default="")
|
||||
province = models.CharField(max_length=64, blank=True, default="")
|
||||
|
||||
@@ -89,13 +89,26 @@ def _classify_and_store(*, site, visitor, session, scope, referrer_url,
|
||||
pass
|
||||
|
||||
|
||||
def _extended_env(ctx):
|
||||
"""扩展环境键快照:白名单键 STR 化截 100,空值跳过(任意键不得经 context 入库渲染)。"""
|
||||
out = {}
|
||||
for k in C.EXTENDED_ENV_KEYS:
|
||||
v = ctx.get(k, "")
|
||||
s = str(v if v is not None else "")
|
||||
if s != "":
|
||||
out[k] = s[:100]
|
||||
return out
|
||||
|
||||
|
||||
def _touch_visitor(site, vid, ctx, ip):
|
||||
env = {k: str(ctx.get(k, "") or "")[:n] for k, n in _FIELD_LEN.items()}
|
||||
# Task16 联调发现:键名映射缺口——SDK context 发 "timezone",模型字段是 timezone_name,
|
||||
# 同名推导的 _FIELD_LEN 取不到值,导致 timezone_name 恒空。
|
||||
env["timezone_name"] = str(ctx.get("timezone", "") or "")[:64]
|
||||
model = str(ctx.get("device_model", "") or "")[:64]
|
||||
v, created = Visitor.objects.get_or_create(
|
||||
site=site, visitor_id=vid, defaults={**env, "last_ip": ip})
|
||||
site=site, visitor_id=vid,
|
||||
defaults={**env, "device_model": model, "env_extra": _extended_env(ctx), "last_ip": ip})
|
||||
now = timezone.now()
|
||||
upd = {"last_seen_at": now} # 聚合字段一律 filter().update(),避免并发下全量 save() 用旧值覆盖赢家
|
||||
if created:
|
||||
@@ -103,8 +116,13 @@ def _touch_visitor(site, vid, ctx, ip):
|
||||
first_utm=_clean_utm(ctx.get("utm")),
|
||||
first_landing_path=(ctx.get("path") or "")[:500],
|
||||
last_active_date=now.date(), active_days=1)
|
||||
elif not Visitor.objects.filter(pk=v.pk, last_active_date=now.date()).exists():
|
||||
upd.update(last_active_date=now.date(), active_days=F("active_days") + 1)
|
||||
else:
|
||||
# 首触快照语义:非首批不覆盖 env_extra 等档案字段——唯一例外是 device_model
|
||||
# 为空且本批有值时补填(UA-CH hints 晚于首批发到的场景,只补写一次)
|
||||
if not v.device_model and model:
|
||||
upd["device_model"] = model
|
||||
if not Visitor.objects.filter(pk=v.pk, last_active_date=now.date()).exists():
|
||||
upd.update(last_active_date=now.date(), active_days=F("active_days") + 1)
|
||||
if ip:
|
||||
upd["last_ip"] = ip
|
||||
g = _geo.resolve(ip)
|
||||
|
||||
@@ -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" # 已有值不再覆盖
|
||||
|
||||
@@ -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"] == {}
|
||||
|
||||
Reference in New Issue
Block a user