feat(sdk): 身份与环境上下文模块

- 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 <noreply@anthropic.com>
This commit is contained in:
浅念
2026-09-15 15:48:17 +08:00
parent fa90835ffa
commit c58e49bf5b
4 changed files with 374 additions and 0 deletions
+121
View File
@@ -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<string, string>;
}
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<string, string> {
const out: Record<string, string> = {};
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),
};
}
+48
View File
@@ -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<string, string> = {};
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;
}
+156
View File
@@ -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;
}
});
});
+49
View File
@@ -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();
});
});