diff --git a/admin/src/api/tracker/visitors.ts b/admin/src/api/tracker/visitors.ts index 0e9491c..c811589 100644 --- a/admin/src/api/tracker/visitors.ts +++ b/admin/src/api/tracker/visitors.ts @@ -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 /** GenericIPAddressField(null=True),未采集到时为 null */ last_ip: string | null first_utm: Record diff --git a/admin/src/views/tracker/visitor/detail.vue b/admin/src/views/tracker/visitor/detail.vue index 5ae9eca..581a1fe 100644 --- a/admin/src/views/tracker/visitor/detail.vue +++ b/admin/src/views/tracker/visitor/detail.vue @@ -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 ] }) diff --git a/admin/src/views/tracker/visitor/index.vue b/admin/src/views/tracker/visitor/index.vue index d10f82f..c79a50f 100644 --- a/admin/src/views/tracker/visitor/index.vue +++ b/admin/src/views/tracker/visitor/index.vue @@ -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 ]) } diff --git a/sdk/src/context.ts b/sdk/src/context.ts index 9bda146..6a9a030 100644 --- a/sdk/src/context.ts +++ b/sdk/src/context.ts @@ -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(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> { + try { + const uad = (navigator as { + userAgentData?: { + getHighEntropyValues?(hints: string[]): Promise>; + }; + }).userAgentData; + if (!uad?.getHighEntropyValues) return {}; + const v = await uad.getHighEntropyValues([ + "model", + "platformVersion", + "architecture", + "bitness", + ]); + const out: Record = {}; + 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) { diff --git a/sdk/src/index.ts b/sdk/src/index.ts index e142d60..613802f 100644 --- a/sdk/src/index.ts +++ b/sdk/src/index.ts @@ -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>((resolve) => setTimeout(() => resolve({}), 2000)), + ]) + .then((hints) => { + if (Object.keys(hints).length && g.__ctk_ctx) + Object.assign(g.__ctk_ctx as Record, hints); + }) + .catch(() => { + /* 静默:绝不冒泡到宿主页(spec §4.5) */ + }); transport = new Transport(cfg); transport.push({ event_id: uuid(), diff --git a/sdk/test/context.test.ts b/sdk/test/context.test.ts index bd6843d..f8df130 100644 --- a/sdk/test/context.test.ts +++ b/sdk/test/context.test.ts @@ -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)[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>) => { + 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"); diff --git a/sdk/test/index.test.ts b/sdk/test/index.test.ts index 533e674..69406e5 100644 --- a/sdk/test/index.test.ts +++ b/sdk/test/index.test.ts @@ -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", diff --git a/server/tracker/api/visitors.py b/server/tracker/api/visitors.py index c9472e6..c70c31b 100644 --- a/server/tracker/api/visitors.py +++ b/server/tracker/api/visitors.py @@ -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, diff --git a/server/tracker/constants.py b/server/tracker/constants.py index d74190e..df22c46 100644 --- a/server/tracker/constants.py +++ b/server/tracker/constants.py @@ -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 版本号 diff --git a/server/tracker/migrations/0005_visitor_device_model_visitor_env_extra.py b/server/tracker/migrations/0005_visitor_device_model_visitor_env_extra.py new file mode 100644 index 0000000..2dfe049 --- /dev/null +++ b/server/tracker/migrations/0005_visitor_device_model_visitor_env_extra.py @@ -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), + ), + ] diff --git a/server/tracker/models.py b/server/tracker/models.py index 3b8c20e..a9a78db 100644 --- a/server/tracker/models.py +++ b/server/tracker/models.py @@ -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="") diff --git a/server/tracker/services/ingest.py b/server/tracker/services/ingest.py index 30e3bef..9fe525c 100644 --- a/server/tracker/services/ingest.py +++ b/server/tracker/services/ingest.py @@ -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) diff --git a/server/tracker/tests/test_ingest.py b/server/tracker/tests/test_ingest.py index ce1639b..58d837a 100644 --- a/server/tracker/tests/test_ingest.py +++ b/server/tracker/tests/test_ingest.py @@ -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": "