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
+4
View File
@@ -18,6 +18,8 @@ export interface VisitorRow {
province: string
city: string
device_type: string
/** 设备型号(UA-CH model,如 Pixel 7;未采集/晚到前为空串) */
device_model: string
os: string
browser: string
language: string
@@ -99,6 +101,8 @@ export interface AttributionBlock {
export interface VisitorDetail extends VisitorRow {
screen: string
timezone_name: string
/** 扩展环境快照(EXTENDED_ENV_KEYS 白名单键,值均为字符串;旧数据为空对象——逐键判空跳行) */
env_extra: Record<string, string>
/** GenericIPAddressField(null=True),未采集到时为 null */
last_ip: string | null
first_utm: Record<string, string>
+57 -3
View File
@@ -428,6 +428,7 @@
import {
Back as BackIcon,
Calendar as CalendarIcon,
Cellphone as CellphoneIcon,
ChatDotRound as ChatDotRoundIcon,
Clock as ClockIcon,
Compass as CompassIcon,
@@ -435,9 +436,13 @@
Cpu as CpuIcon,
Document as DocumentIcon,
FullScreen as FullScreenIcon,
Iphone as IphoneIcon,
Lightning as LightningIcon,
Link as LinkIcon,
Location as LocationIcon,
Monitor as MonitorIcon,
Moon as MoonIcon,
Sunny as SunnyIcon,
Timer as TimerIcon,
User as UserIcon,
VideoPlay as VideoPlayIcon
@@ -617,14 +622,62 @@
{ label: '原始提问', value: searchTermText.value }
])
// ---------- 访问环境(复用档案字段;空值显示 —) ----------
// ---------- 访问环境(档案字段 + 扩展环境快照;空值显示 —,扩展键逐键判空跳行) ----------
/** env_extra 单键取值(空串/缺键 → undefined,对应行跳过——旧数据无扩展键时整块不显示) */
const extra = (key: string): string | undefined => {
const v = detail.value?.env_extra?.[key]
return v ? v : undefined
}
/** orientation 归一显示:portrait* → 竖屏 / landscape* → 横屏(其余原样) */
const formatOrientation = (v: string): string =>
v.startsWith('portrait') ? '竖屏' : v.startsWith('landscape') ? '横屏' : v
/** 网络质量组合值:downlink Mbps + rtt ms + 省流标记(各自存在才拼入;全缺 → undefined 跳行) */
const networkQuality = (): string | undefined => {
const parts: string[] = []
if (extra('network_downlink')) parts.push(`${extra('network_downlink')} Mbps`)
if (extra('network_rtt')) parts.push(`${extra('network_rtt')} ms`)
if (extra('save_data') === '1') parts.push('省流')
return parts.length ? parts.join(' · ') : undefined
}
/** 扩展行构造:值存在才成行(undefined → null 剔除;fmt 做展示格式化) */
const extendedRow = (
value: string | undefined,
icon: Component,
label: string,
fmt: (v: string) => string = (v) => v
): { icon: Component; label: string; value: string } | null =>
value ? { icon: markRaw(icon), label, value: fmt(value) } : null
const envRows = computed<{ icon: Component; label: string; value: string }[]>(() => {
const d = detail.value
const location = d ? [d.country, d.province, d.city].filter(Boolean).join(' · ') : ''
// 设备行升级:有型号时拼 `手机 · Pixel 7`(device_model 是独立列——hints 晚到补填也生效)
const type = d?.device_type || '—'
const deviceValue = d?.device_model ? `${type} · ${d.device_model}` : type
const dark = extra('color_scheme') === 'dark'
// 扩展行只显示存在的键:旧数据 env_extra 为空对象时整块不显示
const extended = [
extendedRow(extra('device_pixel_ratio'), FullScreenIcon, '像素比'),
extendedRow(extra('viewport'), MonitorIcon, '视口'),
extendedRow(extra('orientation'), CellphoneIcon, '屏幕方向', formatOrientation),
extendedRow(extra('max_touch_points'), IphoneIcon, '触点'),
extendedRow(extra('device_memory'), CpuIcon, '内存', (v) => `${v} GB`),
extendedRow(networkQuality(), LightningIcon, '网络质量'),
extendedRow(
extra('color_scheme'),
dark ? MoonIcon : SunnyIcon,
'深浅色',
() => (dark ? '深色' : '浅色')
),
extendedRow(extra('wechat_version'), ChatDotRoundIcon, '微信版本')
].filter((r): r is { icon: Component; label: string; value: string } => r !== null)
return [
{ icon: markRaw(CompassIcon), label: '浏览器', value: d?.browser || '—' },
{ icon: markRaw(CpuIcon), label: '操作系统', value: d?.os || '—' },
{ icon: markRaw(MonitorIcon), label: '设备类型', value: d?.device_type || '—' },
{ icon: markRaw(MonitorIcon), label: '设备类型', value: deviceValue },
{ icon: markRaw(FullScreenIcon), label: '分辨率', value: d?.screen || '—' },
{
icon: markRaw(LocationIcon),
@@ -633,7 +686,8 @@
},
{ icon: markRaw(ChatDotRoundIcon), label: '语言', value: d?.language || '—' },
{ icon: markRaw(ClockIcon), label: '时区', value: d?.timezone_name || '—' },
{ icon: markRaw(ConnectionIcon), label: 'IP', value: d?.last_ip || '—' }
{ icon: markRaw(ConnectionIcon), label: 'IP', value: d?.last_ip || '—' },
...extended
]
})
+5 -1
View File
@@ -230,8 +230,12 @@
const renderDevice = (row: VisitorRow) => {
const env = [row.os, row.browser].filter(Boolean).join(' · ')
// 有型号时主行拼 `手机·Pixel 7`(UA-CH model,未采集/晚到前只有设备类型)
const type = row.device_model
? `${row.device_type || '—'}·${row.device_model}`
: row.device_type || '—'
return h('div', null, [
h('div', { class: 'whitespace-nowrap' }, row.device_type || '—'),
h('div', { class: 'whitespace-nowrap' }, type),
env ? h('div', { class: 'text-xs text-g-500 whitespace-nowrap mt-0.5' }, env) : null
])
}
+87 -3
View File
@@ -21,6 +21,19 @@ export interface EnvContext {
screen: string;
hardware_concurrency?: number;
connection?: string;
// ---- 移动端设备信息最大化采集(可选键:能力缺失/取不到时不挂) ----
device_pixel_ratio?: number;
viewport?: string;
orientation?: string;
max_touch_points?: number;
device_memory?: number;
languages?: string;
color_scheme?: string;
standalone?: string;
network_downlink?: number;
network_rtt?: number;
save_data?: string;
wechat_version?: string;
}
export interface PageContext {
@@ -81,6 +94,20 @@ function detectBrowser(ua: string): string {
return "";
}
/** 单表达式安全取值:任何异常(老浏览器禁 API/沙箱 iframe 等)→ undefined,缺则不挂键 */
function safe<T>(fn: () => T): T | undefined {
try {
return fn();
} catch {
return undefined;
}
}
/** matchMedia 查询(matchMedia 不可用的环境返回 false,调用方以 hasMatchMedia 区分) */
function mm(q: string): boolean {
return safe(() => window.matchMedia(q).matches) === true;
}
export function buildEnvContext(): EnvContext {
const ua = navigator.userAgent;
let timezone = "";
@@ -99,12 +126,69 @@ export function buildEnvContext(): EnvContext {
};
const hw = navigator.hardwareConcurrency;
if (typeof hw === "number") ctx.hardware_concurrency = hw;
const conn = (navigator as { connection?: { effectiveType?: string } }).connection
?.effectiveType;
if (conn) ctx.connection = String(conn);
const conn = (navigator as {
connection?: { effectiveType?: string; downlink?: number; rtt?: number; saveData?: boolean };
}).connection;
if (conn?.effectiveType) ctx.connection = String(conn.effectiveType);
// ---- 移动端设备信息最大化采集:逐键 try/catch 静默(safe),缺则不挂 ----
const dpr = safe(() => window.devicePixelRatio);
if (typeof dpr === "number") ctx.device_pixel_ratio = dpr;
const viewport = safe(() => `${window.innerWidth}x${window.innerHeight}`);
if (viewport) ctx.viewport = viewport;
const orient = safe(
() => (screen as Screen & { orientation?: { type?: string } }).orientation?.type,
);
if (orient) ctx.orientation = String(orient).slice(0, 32);
const hasMM = safe(() => typeof window.matchMedia === "function") === true;
if (!ctx.orientation && hasMM)
ctx.orientation = mm("(orientation: portrait)") ? "portrait" : "landscape";
const mtp = safe(() => navigator.maxTouchPoints);
if (typeof mtp === "number") ctx.max_touch_points = mtp;
const mem = safe(() => (navigator as Navigator & { deviceMemory?: number }).deviceMemory);
if (typeof mem === "number") ctx.device_memory = mem;
const langs = safe(
() => (navigator as Navigator & { languages?: readonly string[] }).languages,
);
if (langs && langs.length) ctx.languages = langs.join(",").slice(0, 100);
if (hasMM) ctx.color_scheme = mm("(prefers-color-scheme: dark)") ? "dark" : "light";
if (hasMM) ctx.standalone = mm("(display-mode: standalone)") ? "1" : "0";
if (conn) {
if (typeof conn.downlink === "number") ctx.network_downlink = conn.downlink;
if (typeof conn.rtt === "number") ctx.network_rtt = conn.rtt;
if (typeof conn.saveData === "boolean") ctx.save_data = conn.saveData ? "1" : "0";
}
const wechat = safe(() => ua.match(/MicroMessenger\/([\d.]+)/));
if (wechat) ctx.wechat_version = wechat[1].slice(0, 32);
return ctx;
}
/** UA-CH 高熵提示(设备型号等的来源):仅 Chromium 系支持,缺失/拒绝/超时由调用方降级 */
export async function collectHighEntropyHints(): Promise<Record<string, string>> {
try {
const uad = (navigator as {
userAgentData?: {
getHighEntropyValues?(hints: string[]): Promise<Record<string, unknown>>;
};
}).userAgentData;
if (!uad?.getHighEntropyValues) return {};
const v = await uad.getHighEntropyValues([
"model",
"platformVersion",
"architecture",
"bitness",
]);
const out: Record<string, string> = {};
if (v.model) out.device_model = String(v.model).slice(0, 64);
if (v.platformVersion) out.platform_version = String(v.platformVersion).slice(0, 32);
if (v.architecture) out.architecture = String(v.architecture).slice(0, 16);
if (v.bitness) out.bitness = String(v.bitness).slice(0, 8);
return out;
} catch {
return {};
}
}
export function pageContext(): PageContext {
let referrer_domain = "";
if (document.referrer) {
+14 -1
View File
@@ -2,7 +2,7 @@ import { uuid } from "./util";
import { mergeConfig } from "./config";
import { Transport } from "./transport";
import { startPageTracking } from "./events";
import { buildEnvContext, pageContext } from "./context";
import { buildEnvContext, collectHighEntropyHints, pageContext } from "./context";
import type { CtkConfig } from "./config";
// IIFE 打包入口(build.mjs):模块加载即接管全局 ctk 命令队列并重放。
@@ -29,6 +29,19 @@ function dispatch(cmd: string, arg?: unknown) {
utm: pc.utm,
path: pc.path,
};
// UA-CH 高熵提示异步并入(2s 超时降级,不阻塞 init):批次 context 是 flush 时读取的,
// 首个 15s flush 窗口内到达即可并入首批;hints 到达前发生的 flush 正常发送(降级可接受)。
Promise.race([
collectHighEntropyHints(),
new Promise<Record<string, string>>((resolve) => setTimeout(() => resolve({}), 2000)),
])
.then((hints) => {
if (Object.keys(hints).length && g.__ctk_ctx)
Object.assign(g.__ctk_ctx as Record<string, unknown>, hints);
})
.catch(() => {
/* 静默:绝不冒泡到宿主页(spec §4.5) */
});
transport = new Transport(cfg);
transport.push({
event_id: uuid(),
+148 -2
View File
@@ -1,5 +1,11 @@
import { describe, it, expect, afterEach } from "vitest";
import { buildEnvContext, parseUtm, sanitizeUrl, pageContext } from "../src/context";
import { describe, it, expect, afterEach, vi } from "vitest";
import {
buildEnvContext,
collectHighEntropyHints,
parseUtm,
sanitizeUrl,
pageContext,
} from "../src/context";
// userAgent 是 Navigator.prototype 上的 accessor;实例上 defineProperty 覆盖,delete 还原
function stubUA(ua: string) {
@@ -149,6 +155,146 @@ describe("buildEnvContext", () => {
});
});
describe("buildEnvContext 扩展采集(移动端设备信息最大化)", () => {
const restore: Array<() => void> = [];
/** 实例属性覆盖 + 注册还原(navigator/screen/window 各 API 均 accessor,defineProperty 覆盖) */
const stub = (obj: object, key: string, value: unknown) => {
Object.defineProperty(obj, key, { value, configurable: true });
restore.push(() => delete (obj as Record<string, unknown>)[key]);
};
/** 按查询串精确命中的 matchMedia 桩(orientation/color_scheme/standalone 共用) */
const stubMatchMedia = (matched: string[] = []) => {
vi.stubGlobal(
"matchMedia",
(q: string) => ({ matches: matched.includes(q), media: q }),
);
};
afterEach(() => {
restore.splice(0).forEach((fn) => fn());
vi.unstubAllGlobals();
});
it("同步扩展键全量采集(dpr/视口/方向/触点/内存/语言/深浅色/standalone/网络/微信版本)", () => {
stubUA(UA_ANDROID_WECHAT);
stub(window, "devicePixelRatio", 3);
stub(window, "innerWidth", 390);
stub(window, "innerHeight", 844);
stub(screen, "orientation", { type: "portrait-primary" });
stub(navigator, "maxTouchPoints", 5);
stub(navigator, "deviceMemory", 8);
stub(navigator, "languages", ["zh-CN", "en", "ja"]);
stub(navigator, "connection", {
effectiveType: "4g",
downlink: 10,
rtt: 50,
saveData: true,
});
stubMatchMedia(["(prefers-color-scheme: dark)"]); // orientation 走 screen.orientation,深色走桩
const env = buildEnvContext();
expect(env.device_pixel_ratio).toBe(3);
expect(env.viewport).toBe("390x844");
expect(env.orientation).toBe("portrait-primary"); // screen.orientation.type 优先
expect(env.max_touch_points).toBe(5);
expect(env.device_memory).toBe(8);
expect(env.languages).toBe("zh-CN,en,ja");
expect(env.color_scheme).toBe("dark");
expect(env.standalone).toBe("0"); // 桩未命中 standalone 查询
expect(env.network_downlink).toBe(10);
expect(env.network_rtt).toBe(50);
expect(env.save_data).toBe("1");
expect(env.wechat_version).toBe("8.0.49"); // UA 的 MicroMessenger/8.0.49
});
it("screen.orientation 缺失 → matchMedia 兜底 portrait/landscape", () => {
stubMatchMedia(["(orientation: portrait)"]);
expect(buildEnvContext().orientation).toBe("portrait");
stubMatchMedia([]); // portrait 不匹配 → landscape
expect(buildEnvContext().orientation).toBe("landscape");
});
it("languages 超 100 截断;saveData 布尔转 1/0", () => {
stub(navigator, "languages", ["a".repeat(60), "b".repeat(60)]);
stub(navigator, "connection", { effectiveType: "3g", saveData: false });
const env = buildEnvContext();
expect(env.languages).toHaveLength(100);
expect(env.save_data).toBe("0");
});
it("能力缺失环境:可选键不挂、不崩溃(无 matchMedia/触点/内存/连接)", () => {
stubUA(UA_WIN_CHROME); // 无 MicroMessenger → 无 wechat_version
stub(window, "matchMedia", undefined); // matchMedia 不可用
stub(navigator, "maxTouchPoints", undefined);
stub(navigator, "languages", undefined);
const env = buildEnvContext();
expect("orientation" in env).toBe(false); // screen.orientation 与 matchMedia 双缺
expect("color_scheme" in env).toBe(false);
expect("standalone" in env).toBe(false);
expect("max_touch_points" in env).toBe(false);
expect("languages" in env).toBe(false);
expect("device_memory" in env).toBe(false);
expect("network_downlink" in env).toBe(false);
expect("save_data" in env).toBe(false);
expect("wechat_version" in env).toBe(false);
// 基础键照常(老环境只是缺扩展信号,主采集不受影响)
expect(env.device_type).toBe("PC");
expect(env.os).toBe("Windows");
});
});
describe("collectHighEntropyHints(UA-CH 高熵提示)", () => {
const restore: Array<() => void> = [];
const stubUAD = (impl: (hints: string[]) => Promise<Record<string, unknown>>) => {
Object.defineProperty(navigator, "userAgentData", {
value: { getHighEntropyValues: impl },
configurable: true,
});
restore.push(() => delete (navigator as { userAgentData?: unknown }).userAgentData);
};
afterEach(() => restore.splice(0).forEach((fn) => fn()));
it("getHighEntropyValues → device_model/platform_version/architecture/bitness", async () => {
stubUAD(async () => ({
model: "Pixel 7",
platformVersion: "13.0.0",
architecture: "arm",
bitness: "64",
fullVersionList: [], // 未请求的键忽略
}));
expect(await collectHighEntropyHints()).toEqual({
device_model: "Pixel 7",
platform_version: "13.0.0",
architecture: "arm",
bitness: "64",
});
});
it("型号超长截断(64/32/16/8 各档)", async () => {
stubUAD(async () => ({
model: "M".repeat(80),
platformVersion: "P".repeat(40),
architecture: "A".repeat(20),
bitness: "B".repeat(10),
}));
const hints = await collectHighEntropyHints();
expect(hints.device_model).toHaveLength(64);
expect(hints.platform_version).toHaveLength(32);
expect(hints.architecture).toHaveLength(16);
expect(hints.bitness).toHaveLength(8);
});
it("空值键不挂(model 空串等)", async () => {
stubUAD(async () => ({ model: "", platformVersion: undefined, architecture: null }));
expect(await collectHighEntropyHints()).toEqual({});
});
it("无 userAgentData / getHighEntropyValues 抛异常 → 空对象不崩", async () => {
expect(await collectHighEntropyHints()).toEqual({}); // happy-dom 默认无 userAgentData
stubUAD(() => Promise.reject(new Error("denied")));
expect(await collectHighEntropyHints()).toEqual({});
});
});
describe("pageContext", () => {
it("path 清洗 + utm 提取 + title 读取", async () => {
await (window as any).happyDOM.setURL("http://localhost/p?token=1&utm_source=g");
+34
View File
@@ -141,6 +141,40 @@ describe("index 入口", () => {
]);
});
it("UA-CH hints 异步并入 __ctk_ctx:晚于 init 到达,flush 时随批次 context 发出", async () => {
// 1s 后才 resolve 的高熵桩(fake timers 控制):保证 init 同步完成时 hints 确实未到
(navigator as { userAgentData?: unknown }).userAgentData = {
getHighEntropyValues: () =>
new Promise((resolve) => setTimeout(() => resolve({ model: "Pixel 7", bitness: "64" }), 1000)),
};
await loadSdk([["init", { siteId: "site-hints", endpoint: "/e", flushMs: 60000 }]]);
expect(g.__ctk_ctx!.device_model).toBeUndefined(); // hints 未到(1s < 已流逝 0ms)
await vi.advanceTimersByTimeAsync(1000); // hints 到达(先于 2s 超时)
expect(g.__ctk_ctx!.device_model).toBe("Pixel 7");
expect(g.__ctk_ctx!.platform_version).toBeUndefined(); // 未提供的键不挂
g.ctk!("flush");
const bodies = bodiesOf("site-hints");
expect(bodies).toHaveLength(1);
expect(bodies[0].context.device_model).toBe("Pixel 7"); // 并入后 flush 的批次携带
});
it("无 userAgentData 环境:hints 降级为空,__ctk_ctx 不挂键不崩(未捕获拒绝为零)", async () => {
const unhandled: unknown[] = [];
const onRej = (e: unknown) => unhandled.push(e);
const proc = (globalThis as { process?: { on(ev: "unhandledRejection", cb: (e: unknown) => void): void; off(ev: "unhandledRejection", cb: (e: unknown) => void): void } }).process;
proc?.on("unhandledRejection", onRej);
await loadSdk([["init", { siteId: "site-nohints", endpoint: "/e", flushMs: 60000 }]]);
await vi.advanceTimersByTimeAsync(2500); // 超过 2s 降级窗口
proc?.off("unhandledRejection", onRej);
expect("device_model" in (g.__ctk_ctx ?? {})).toBe(false);
expect(unhandled).toEqual([]);
g.ctk!("flush"); // 降级批次照常发送
expect(bodiesOf("site-nohints")).toHaveLength(1);
});
it("init 前队列重放:排队的 track 事件入队,g.ctk 已替换为 dispatch", async () => {
const trackEv = {
event_id: "e-custom",
+3
View File
@@ -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,
+6
View File
@@ -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),
),
]
+2
View File
@@ -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="")
+21 -3
View File
@@ -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)
+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"] == {}