From c58e49bf5ba52e88d723b65696ab98d9cbe794f4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=B5=85=E5=BF=B5?= Date: Tue, 15 Sep 2026 15:48:17 +0800 Subject: [PATCH] =?UTF-8?q?feat(sdk):=20=E8=BA=AB=E4=BB=BD=E4=B8=8E?= =?UTF-8?q?=E7=8E=AF=E5=A2=83=E4=B8=8A=E4=B8=8B=E6=96=87=E6=A8=A1=E5=9D=97?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - identity.ts: 访客/会话 ID(30min 空闲轮换),localStorage 异常统一内存降级不冒泡(spec §4.5 红线) - context.ts: buildEnvContext/parseUtm/sanitizeUrl/pageContext;参数黑名单与 server URL_PARAM_DROP_PATTERNS 同源(整键精确匹配、大小写不敏感、保留空值) - 测试 21 过(含隐私模式降级、Edg 先于 Chrome、微信 UA 判定、iPad-Mobile 规格顺序锁定) Co-Authored-By: Claude Code --- sdk/src/context.ts | 121 +++++++++++++++++++++++++++++ sdk/src/identity.ts | 48 ++++++++++++ sdk/test/context.test.ts | 156 ++++++++++++++++++++++++++++++++++++++ sdk/test/identity.test.ts | 49 ++++++++++++ 4 files changed, 374 insertions(+) create mode 100644 sdk/src/context.ts create mode 100644 sdk/src/identity.ts create mode 100644 sdk/test/context.test.ts create mode 100644 sdk/test/identity.test.ts diff --git a/sdk/src/context.ts b/sdk/src/context.ts new file mode 100644 index 0000000..210c7d9 --- /dev/null +++ b/sdk/src/context.ts @@ -0,0 +1,121 @@ +// 与 server/tracker/constants.py 的 URL_PARAM_DROP_PATTERNS 同源: +// ("email", "phone", "mobile", "token", "key", "password", "verif"), +// 整键精确匹配(大小写不敏感,与服务端 re.I + fullmatch 一致)——SDK 先删一遍, +// 服务端 sanitize_url 再删一遍(双重清洗),子串参数(如 monkey 含 key)不误伤。 +const URL_PARAM_DROP = new Set([ + "email", + "phone", + "mobile", + "token", + "key", + "password", + "verif", +]); + +export interface EnvContext { + device_type: string; + os: string; + browser: string; + language: string; + timezone: string; + screen: string; + hardware_concurrency?: number; + connection?: string; +} + +export interface PageContext { + path: string; + title: string; + referrer: string; + referrer_domain: string; + utm: Record; +} + +export function sanitizeUrl(pathAndQuery: string): string { + const i = pathAndQuery.indexOf("?"); + if (i === -1) return pathAndQuery; + const path = pathAndQuery.slice(0, i); + const kept: string[] = []; + new URLSearchParams(pathAndQuery.slice(i + 1)).forEach((v, k) => { + if (!URL_PARAM_DROP.has(k.toLowerCase())) { + kept.push(`${encodeURIComponent(k)}=${encodeURIComponent(v)}`); + } + }); + return kept.length ? `${path}?${kept.join("&")}` : path; +} + +export function parseUtm(search: string): Record { + const out: Record = {}; + new URLSearchParams(search).forEach((v, k) => { + if (k.startsWith("utm_")) out[k.slice(0, 100)] = v.slice(0, 100); + }); + return out; +} + +function detectDeviceType(ua: string): string { + if (/Mobile|Android|iPhone/.test(ua)) return "手机"; + if (/iPad|Tablet/.test(ua)) return "平板"; + return "PC"; +} + +function detectOs(ua: string): string { + if (/Windows/.test(ua)) return "Windows"; + if (/Android/.test(ua)) return "Android"; + if (/iPhone|iPad|iPod/.test(ua)) return "iOS"; + if (/Macintosh|Mac OS X/.test(ua)) return "Mac"; + if (/Linux/.test(ua)) return "Linux"; + return ""; +} + +function detectBrowser(ua: string): string { + // MicroMessenger 必须最先测:微信 WebView 的 UA 内嵌 Chrome/Safari 引擎串, + // 与 "Edg 必须先于 Chrome" 同理(后测永远轮不到)。 + if (/MicroMessenger/.test(ua)) return "WeChat"; + if (/Edg(e|A|iOS)?\//.test(ua)) return "Edge"; + if (/Chrome\/|CriOS\//.test(ua)) return "Chrome"; + if (/Safari\//.test(ua)) return "Safari"; + if (/Firefox\/|FxiOS\//.test(ua)) return "Firefox"; + return ""; +} + +export function buildEnvContext(): EnvContext { + const ua = navigator.userAgent; + let timezone = ""; + try { + timezone = Intl.DateTimeFormat().resolvedOptions().timeZone || ""; + } catch { + timezone = ""; // 老浏览器/禁用 Intl 的环境降级 + } + const ctx: EnvContext = { + device_type: detectDeviceType(ua), + os: detectOs(ua), + browser: detectBrowser(ua), + language: navigator.language, + timezone, + screen: `${screen.width}x${screen.height}`, + }; + 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); + return ctx; +} + +export function pageContext(): PageContext { + let referrer_domain = ""; + if (document.referrer) { + try { + referrer_domain = new URL(document.referrer).hostname; + } catch { + referrer_domain = ""; // urlsplit 语义:解析不了的 referrer 归空 + } + } + return { + path: sanitizeUrl(location.pathname + location.search), + title: document.title, + referrer: document.referrer, + referrer_domain, + utm: parseUtm(location.search), + }; +} diff --git a/sdk/src/identity.ts b/sdk/src/identity.ts new file mode 100644 index 0000000..fb37860 --- /dev/null +++ b/sdk/src/identity.ts @@ -0,0 +1,48 @@ +import { uuid } from "./util"; + +const K = { vid: "ctk_vid", sid: "ctk_sid", act: "ctk_act" }; +const IDLE = 30 * 60_000; + +// localStorage 可能抛异常(隐私模式/禁存储,Safari ITP):统一走带内存降级的 store, +// 任何路径都不得让异常冒泡到宿主页(spec §4.5 红线) +const mem: Record = {}; +const store = { + get(k: string): string | null { + try { + return localStorage.getItem(k); + } catch { + return mem[k] ?? null; + } + }, + set(k: string, v: string) { + try { + localStorage.setItem(k, v); + } catch { + mem[k] = v; + } + }, +}; + +export function getVisitorId(): string { + let v = store.get(K.vid); + if (!v) { + v = uuid(); + store.set(K.vid, v); + } + return v; +} + +export function touchActivity() { + store.set(K.act, String(Date.now())); +} + +export function getSessionId(): string { + const last = +(store.get(K.act) || 0); + let s = store.get(K.sid); + if (!s || Date.now() - last > IDLE) { + s = uuid(); + store.set(K.sid, s); + } + touchActivity(); + return s; +} diff --git a/sdk/test/context.test.ts b/sdk/test/context.test.ts new file mode 100644 index 0000000..334f631 --- /dev/null +++ b/sdk/test/context.test.ts @@ -0,0 +1,156 @@ +import { describe, it, expect, afterEach } from "vitest"; +import { buildEnvContext, parseUtm, sanitizeUrl, pageContext } from "../src/context"; + +// userAgent 是 Navigator.prototype 上的 accessor;实例上 defineProperty 覆盖,delete 还原 +function stubUA(ua: string) { + Object.defineProperty(navigator, "userAgent", { value: ua, configurable: true }); +} +afterEach(() => { + delete (navigator as { userAgent?: string }).userAgent; +}); + +const UA_IPHONE_SAFARI = + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15 (Version/17.0 Mobile/15E148 Safari/604.1"; +const UA_TABLET = + "Mozilla/5.0 (X11; Tablet; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/121.0.0.0 Safari/537.36"; +// 真实 iPad Safari UA 恒含 Mobile 串——按任务规格的判定顺序(Mobile 先于 iPad) +// 会落入"手机",此处锁定该规格行为,避免后人无意"修复" +const UA_IPAD_REAL = + "Mozilla/5.0 (iPad; CPU OS 16_0 like Mac OS X) AppleWebKit/605.1.15 (Version/16.0 Mobile/15E148 Safari/604.1"; +const UA_WIN_CHROME = + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/121.0.0.0 Safari/537.36"; +const UA_WIN_EDGE = + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/121.0.0.0 Safari/537.36 Edg/121.0.0.0"; +const UA_ANDROID_WECHAT = + "Mozilla/5.0 (Linux; Android 13; Pixel 7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/119.0.0.0 Mobile Safari/537.36 MicroMessenger/8.0.49"; + +describe("parseUtm", () => { + it("只保留 utm_ 前缀参数", () => { + expect(parseUtm("?utm_source=google&utm_medium=cpc&other=1&q=中文")).toEqual({ + utm_source: "google", + utm_medium: "cpc", + }); + }); + + it("键值各截 100 字符", () => { + const long = "x".repeat(150); + const out = parseUtm(`?utm_${long}=${long}`); + expect(Object.keys(out)).toHaveLength(1); + const key = Object.keys(out)[0]!; + expect(key).toHaveLength(100); + expect(out[key]).toHaveLength(100); + }); + + it("无 utm 参数时返回空对象", () => { + expect(parseUtm("?a=1&b=2")).toEqual({}); + }); +}); + +describe("sanitizeUrl", () => { + it("删除黑名单参数,保留其余(含空值),不误伤 monkey", () => { + expect(sanitizeUrl("/p?email=a@b.com&token=x&id=3&utm_source=g&monkey=1")).toBe( + "/p?id=3&utm_source=g&monkey=1", + ); + }); + + it("空值参数保留", () => { + expect(sanitizeUrl("/p?empty=&id=1")).toBe("/p?empty=&id=1"); + }); + + it("黑名单整键精确匹配(大小写不敏感),子串参数不删", () => { + expect(sanitizeUrl("/p?Token=1&email=x@y.z&monkey=1&utoken=2")).toBe("/p?monkey=1&utoken=2"); + }); + + it("全部被删时不残留 ?,无查询串时原样返回", () => { + expect(sanitizeUrl("/p?token=1&password=2")).toBe("/p"); + expect(sanitizeUrl("/p/plain")).toBe("/p/plain"); + }); +}); + +describe("buildEnvContext", () => { + it("happy-dom 默认 UA:含全部必需键且 device_type 合法", () => { + // 默认 UA: Mozilla/5.0 (X11; Win32 x64) AppleWebKit/537.36 ... HappyDOM/0.0.0 + const env = buildEnvContext(); + expect(env.device_type).toBe("PC"); // 默认 UA 无 Mobile/Android/iPhone/iPad/Tablet + expect(["PC", "手机", "平板"]).toContain(env.device_type); + expect(typeof env.os).toBe("string"); + expect(typeof env.browser).toBe("string"); + expect(typeof env.language).toBe("string"); + expect(env.language).toBe(navigator.language); + expect(typeof env.timezone).toBe("string"); + expect(env.screen).toBe(`${screen.width}x${screen.height}`); + expect(typeof env.hardware_concurrency).toBe("number"); // happy-dom 提供 + expect("connection" in env).toBe(false); // happy-dom 无 connection + }); + + it("iPhone UA → 手机 + iOS + Safari", () => { + stubUA(UA_IPHONE_SAFARI); + const env = buildEnvContext(); + expect(env.device_type).toBe("手机"); + expect(env.os).toBe("iOS"); + expect(env.browser).toBe("Safari"); + }); + + it("Tablet UA(无 Mobile/Android/iPhone 串)→ 平板", () => { + stubUA(UA_TABLET); + expect(buildEnvContext().device_type).toBe("平板"); + }); + + it("真实 iPad UA(含 Mobile 串)→ 手机:规格判定顺序 Mobile 先于 iPad", () => { + stubUA(UA_IPAD_REAL); + const env = buildEnvContext(); + expect(env.device_type).toBe("手机"); + expect(env.os).toBe("iOS"); + }); + + it("Windows Chrome UA → PC + Windows + Chrome", () => { + stubUA(UA_WIN_CHROME); + const env = buildEnvContext(); + expect(env.device_type).toBe("PC"); + expect(env.os).toBe("Windows"); + expect(env.browser).toBe("Chrome"); + }); + + it("Edg UA(内嵌 Chrome 串)→ browser 为 Edge,验证 Edg 先于 Chrome", () => { + stubUA(UA_WIN_EDGE); + const env = buildEnvContext(); + expect(env.browser).toBe("Edge"); + expect(env.os).toBe("Windows"); + }); + + it("微信 UA(内嵌 Chrome/Safari 串)→ browser 为 WeChat", () => { + stubUA(UA_ANDROID_WECHAT); + const env = buildEnvContext(); + expect(env.browser).toBe("WeChat"); + expect(env.device_type).toBe("手机"); + expect(env.os).toBe("Android"); + }); +}); + +describe("pageContext", () => { + it("path 清洗 + utm 提取 + title 读取", async () => { + await (window as any).happyDOM.setURL("http://localhost/p?token=1&utm_source=g"); + document.title = "测试页"; + const ctx = pageContext(); + expect(ctx.path).toBe("/p?utm_source=g"); // token 已删,utm_source 保留 + expect(ctx.title).toBe("测试页"); + expect(ctx.utm).toEqual({ utm_source: "g" }); + expect(ctx.referrer).toBe(""); + expect(ctx.referrer_domain).toBe(""); + }); + + it("referrer_domain 提取 hostname,非法/空 referrer 降级空串", () => { + const stub = (v: string) => + Object.defineProperty(document, "referrer", { value: v, configurable: true }); + try { + stub("https://www.google.com/search?q=x"); + expect(pageContext().referrer_domain).toBe("www.google.com"); + stub("not a url"); + expect(pageContext().referrer_domain).toBe(""); + stub(""); + expect(pageContext().referrer_domain).toBe(""); + } finally { + delete (document as { referrer?: string }).referrer; + } + }); +}); diff --git a/sdk/test/identity.test.ts b/sdk/test/identity.test.ts new file mode 100644 index 0000000..b7c714d --- /dev/null +++ b/sdk/test/identity.test.ts @@ -0,0 +1,49 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; +import { getVisitorId, getSessionId, touchActivity } from "../src/identity"; + +describe("identity", () => { + beforeEach(() => { + localStorage.clear(); + vi.useFakeTimers(); + }); + afterEach(() => { + vi.restoreAllMocks(); + vi.useRealTimers(); + }); + + it("visitor_id 持久化且格式正确", () => { + const v = getVisitorId(); + expect(v).toBe(getVisitorId()); + expect(v).toMatch(/^[0-9a-f-]{36}$/); + }); + + it("30 分钟无活动切新会话;窗口内 touch 续期", () => { + const s1 = getSessionId(); + vi.advanceTimersByTime(10 * 60_000); + touchActivity(); + vi.advanceTimersByTime(10 * 60_000); + expect(getSessionId()).toBe(s1); // 20min 内有活动 → 同会话 + vi.advanceTimersByTime(31 * 60_000); // 再过 31min 无活动 + expect(getSessionId()).not.toBe(s1); + }); + + it("localStorage 抛异常时内存降级不冒泡且功能可用", () => { + // 探测确认:vitest happy-dom 环境下 window === globalThis,且 localStorage + // 是 window 上的自有可配置 accessor——spy 其 getter 即可让模块内裸 + // localStorage 引用在访问时抛异常(等效隐私模式/禁存储)。 + const spy = vi.spyOn(window, "localStorage", "get").mockImplementation(() => { + throw new DOMException("denied"); + }); + + const v = getVisitorId(); + expect(v).toMatch(/^[0-9a-f-]{36}$/); + const s = getSessionId(); + expect(s).toMatch(/^[0-9a-f-]{36}$/); + expect(getSessionId()).toBe(s); // 纯内存降级下会话仍连续 + expect(() => touchActivity()).not.toThrow(); + + // 还原后(内存态无法迁移到 localStorage 是预期)混用两套存储不抛 + spy.mockRestore(); + expect(() => getSessionId()).not.toThrow(); + }); +});