feat: 移动端设备信息最大化采集(型号/UA-CH/视口/方向/触点/内存/网络/深浅色)

SDK:buildEnvContext 同步扩展 12 键(dpr/viewport/orientation/maxTouchPoints/
deviceMemory/languages/colorScheme/standalone/network downlink·rtt·saveData/
微信版本),逐键 try/catch 静默缺则不挂;新增 collectHighEntropyHints(UA-CH
model/platformVersion/architecture/bitness),init 后 Promise.race 2s 超时
异步并入 __ctk_ctx——批次 context flush 时读取,不阻塞首屏。

后端:Visitor 加 device_model/env_extra(迁移 0005);ingest 首批快照
EXTENDED_ENV_KEYS 白名单(值截 100),非首批不覆盖、唯 device_model 空且
本批有值时补填一次(晚到 hints);列表/详情 API 随行输出。

前端:详情访问环境卡设备行升级显示型号(手机 · Pixel 7),追加像素比/
视口/屏幕方向/触点/内存/网络质量/深浅色/微信版本行(env_extra 逐键判空,
旧数据整块不显示);列表设备列主行拼型号(手机·Pixel 7)。

Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
浅念
2026-09-16 19:12:20 +08:00
parent 7f2a746f66
commit 22d89a3f42
14 changed files with 484 additions and 14 deletions
+34
View File
@@ -141,6 +141,40 @@ describe("index 入口", () => {
]);
});
it("UA-CH hints 异步并入 __ctk_ctx:晚于 init 到达,flush 时随批次 context 发出", async () => {
// 1s 后才 resolve 的高熵桩(fake timers 控制):保证 init 同步完成时 hints 确实未到
(navigator as { userAgentData?: unknown }).userAgentData = {
getHighEntropyValues: () =>
new Promise((resolve) => setTimeout(() => resolve({ model: "Pixel 7", bitness: "64" }), 1000)),
};
await loadSdk([["init", { siteId: "site-hints", endpoint: "/e", flushMs: 60000 }]]);
expect(g.__ctk_ctx!.device_model).toBeUndefined(); // hints 未到(1s < 已流逝 0ms)
await vi.advanceTimersByTimeAsync(1000); // hints 到达(先于 2s 超时)
expect(g.__ctk_ctx!.device_model).toBe("Pixel 7");
expect(g.__ctk_ctx!.platform_version).toBeUndefined(); // 未提供的键不挂
g.ctk!("flush");
const bodies = bodiesOf("site-hints");
expect(bodies).toHaveLength(1);
expect(bodies[0].context.device_model).toBe("Pixel 7"); // 并入后 flush 的批次携带
});
it("无 userAgentData 环境:hints 降级为空,__ctk_ctx 不挂键不崩(未捕获拒绝为零)", async () => {
const unhandled: unknown[] = [];
const onRej = (e: unknown) => unhandled.push(e);
const proc = (globalThis as { process?: { on(ev: "unhandledRejection", cb: (e: unknown) => void): void; off(ev: "unhandledRejection", cb: (e: unknown) => void): void } }).process;
proc?.on("unhandledRejection", onRej);
await loadSdk([["init", { siteId: "site-nohints", endpoint: "/e", flushMs: 60000 }]]);
await vi.advanceTimersByTimeAsync(2500); // 超过 2s 降级窗口
proc?.off("unhandledRejection", onRej);
expect("device_model" in (g.__ctk_ctx ?? {})).toBe(false);
expect(unhandled).toEqual([]);
g.ctk!("flush"); // 降级批次照常发送
expect(bodiesOf("site-nohints")).toHaveLength(1);
});
it("init 前队列重放:排队的 track 事件入队,g.ctk 已替换为 dispatch", async () => {
const trackEv = {
event_id: "e-custom",