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