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:
@@ -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),
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user