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:
@@ -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>
|
||||
|
||||
@@ -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
|
||||
]
|
||||
})
|
||||
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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");
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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