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;
}