@jx3box/jx3box-ui 2.4.2 → 2.4.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,50 @@
1
+ # Traffic 路径采集适配
2
+
3
+ `jx3box-ui` 只提供无渲染、显式启用的适配器,不会在安装组件库或渲染 `CommonHeader` 时自动采集。
4
+
5
+ ```js
6
+ import { createJx3boxTrafficAnalytics } from "@jx3box/jx3box-ui";
7
+
8
+ const traffic = createJx3boxTrafficAnalytics({
9
+ router,
10
+ project: "index",
11
+ client: "pc_web",
12
+ surface: "pc_web",
13
+ gameClient: "std",
14
+ // 传 heartbeat 的实时结果。未知态失败关闭,不发送。
15
+ resolveTrafficPermission: () => heartbeatState,
16
+ });
17
+
18
+ // 应在 router 已建立,并取得 heartbeat 权限后显式执行。
19
+ await traffic.init();
20
+
21
+ // heartbeat 后续发现机器人或关闭采集时,立即持久阻断并清空队列。
22
+ traffic.setTrafficPermission({
23
+ traffic_allowed: false,
24
+ collection_blocked_reason: "robot",
25
+ });
26
+
27
+ // heartbeat 明确恢复后才解除持久阻断。
28
+ traffic.setTrafficPermission({ traffic_allowed: true });
29
+
30
+ // 同义的显式生命周期接口,供宿主的隐私开关直接调用。
31
+ traffic.block("host_privacy_disabled");
32
+ traffic.unblock();
33
+
34
+ // 宿主卸载时释放唯一 Router owner、pagehide 监听和队列请求。
35
+ traffic.destroy();
36
+ ```
37
+
38
+ 适配器只创建一个 Analytics Core、一个 Router owner 和一个 `TrafficSink`。它不会创建 `TrackingSink`,也不会安装点击、曝光、热力图指令,因此一次路由变化只产生一个 canonical `page_view`。已经由宿主启用 Tracking 的应用不能再初始化本适配器;这类应用应直接用 `jx3box-common@9.5.2` 的组合 sink 创建唯一 Core/Router owner。
39
+
40
+ 运行时规则只能来自 `/api/cms/system/traffic/config`。配置缺失、请求失败、规则字段不完整或动态参数没有 named validator 时不建立页面事件,也不使用本地放行规则。heartbeat 权限未知时会进入内存级 fail-closed:不建立新的 Journal 事件、取消进行中的请求;已经完成脱敏的 Journal 项会保留,直到明确允许后再恢复。宿主应优先等 heartbeat 给出明确权限后再调用 `init()`。批量请求发往 `/api/cms/system/traffic/visits/batch`,依赖逐 `event_id` ACK;409、429、网络错误和缺失 ACK 都保留原事件重试。
41
+
42
+ Config 请求与 Traffic envelope 共用同一个 recipient domain。`pc_web` 保留小写真实 hostname(由后端 allowlist 决定是否启用);`app`、`miniprogram`、`pc_game`、`mobile_game` 统一使用 `embedded`,不会把 WebView 的 `localhost` 等内部地址发给规则接口。
43
+
44
+ `traffic_allowed=false`、`is_robot=true`、`traffic.block(reason)` 或可信 `blocked` 会写入 `jx3box:analytics:traffic:block:v1`,并调用 `block({ clear: true, cancelInflight: true })`。下次初始化先恢复阻断,再接入 Router。只有 heartbeat 明确返回允许,或宿主显式调用 `setTrafficPermission({ traffic_allowed: true })` / `unblock()` 才解除。
45
+
46
+ 宿主可通过 `beforeFlush(context)` 在每次网络发送前追加权限判断;返回 `false`/`{ allow: false }` 会保留队列并拒绝发送,返回可信 `block` 会持久阻断。`clear(reason)` 与 `cancelInflight(reason)` 分别清除安全 Journal 和取消当前请求。`init()` 幂等占有唯一 Router owner,`destroy()` 释放 Router/pagehide 监听并取消请求;销毁后不能再次初始化。
47
+
48
+ Traffic-only 队列使用独立的 `jx3box:analytics:traffic:queue:v1`,不会读取或改写公共 `jx3box:analytics:queue:v1` 中的 Tracking/multi-sink Journal。队列仍把无 delivery ownership 的旧数据视为 `tracking`,不会误发到 Traffic。
49
+
50
+ 后端协议已提供 Traffic config 和逐 ID ACK。本适配器当前通过 9.5.2 fixture 验证;若目标环境仍是旧部署、响应缺字段或网络不可用,会安全保留或拒绝事件。构建与 fixture 测试不代表线上部署及真实链路已经验证。
package/index.js CHANGED
@@ -43,6 +43,7 @@ import {
43
43
  setJx3boxUiLocale,
44
44
  } from "./i18n";
45
45
  import i18nMixin from "./i18n/mixin";
46
+ import { createJx3boxTrafficAnalytics } from "./src/utils/traffic-analytics";
46
47
 
47
48
  const components = {
48
49
  CommonHeader,
@@ -93,4 +94,5 @@ export {
93
94
  mergeJx3boxUiMessages,
94
95
  getJx3boxUiAvailableLocales,
95
96
  setJx3boxUiLocale,
97
+ createJx3boxTrafficAnalytics,
96
98
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jx3box/jx3box-ui",
3
- "version": "2.4.2",
3
+ "version": "2.4.3",
4
4
  "description": "JX3BOX Vue3 UI",
5
5
  "main": "index.js",
6
6
  "scripts": {
@@ -13,6 +13,7 @@
13
13
  "build:dev": "cross-env NODE_OPTIONS=--max-old-space-size=4096 vue-cli-service build --mode development",
14
14
  "build:prod": "cross-env NODE_OPTIONS=--max-old-space-size=4096 vue-cli-service build --mode production",
15
15
  "lint": "vue-cli-service lint",
16
+ "test:traffic": "node --test tests/traffic-analytics-contract.test.js",
16
17
  "test:i18n": "node --test tests/wiki-i18n-contract.test.js",
17
18
  "update": "npm --registry https://registry.npmjs.org install @jx3box/jx3box-common@latest @jx3box/jx3box-data@latest @jx3box/jx3box-editor@latest @jx3box/jx3box-emotion@latest @jx3box/jx3box-macro@latest @jx3box/jx3box-talent@latest"
18
19
  },
@@ -33,7 +34,7 @@
33
34
  },
34
35
  "dependencies": {
35
36
  "@element-plus/icons-vue": "^2.3.2",
36
- "@jx3box/jx3box-common": "^9.2.9",
37
+ "@jx3box/jx3box-common": "9.5.2",
37
38
  "@jx3box/jx3box-data": "^3.9.6",
38
39
  "@jx3box/jx3box-editor": "^3.2.13",
39
40
  "@jx3box/jx3box-emotion": "^1.3.2",
@@ -0,0 +1,443 @@
1
+ import {
2
+ createAnalyticsCore,
3
+ createCompositeRuleResolver,
4
+ createEventQueue,
5
+ createIdentity,
6
+ createQueueStorage,
7
+ createRemoteRuleResolver,
8
+ createTrafficSink,
9
+ defaultAckDecoder,
10
+ installVueRouterAnalytics,
11
+ } from "@jx3box/jx3box-common/js/analytics.js";
12
+
13
+ const DEFAULT_CONFIG_ENDPOINT = "/api/cms/system/traffic/config";
14
+ const DEFAULT_BATCH_ENDPOINT = "/api/cms/system/traffic/visits/batch";
15
+ const DEFAULT_QUEUE_STORAGE_KEY = "jx3box:analytics:traffic:queue:v1";
16
+ const DEFAULT_BLOCK_STORAGE_KEY = "jx3box:analytics:traffic:block:v1";
17
+ const EMBEDDED_SURFACES = new Set(["app", "miniprogram", "pc_game", "mobile_game"]);
18
+
19
+ function safeReason(value, fallback) {
20
+ const reason = String(value || fallback || "traffic_disabled")
21
+ .trim()
22
+ .slice(0, 128);
23
+ return /^[a-zA-Z0-9][a-zA-Z0-9_.:-]*$/.test(reason) ? reason : "traffic_disabled";
24
+ }
25
+
26
+ function safeStorage(storage) {
27
+ if (!storage || typeof storage.getItem !== "function") return null;
28
+ return storage;
29
+ }
30
+
31
+ function normalizeRecipientDomain(domain, surface) {
32
+ const normalizedSurface = String(surface || "")
33
+ .trim()
34
+ .toLowerCase();
35
+ if (EMBEDDED_SURFACES.has(normalizedSurface)) return "embedded";
36
+ const normalizedDomain = String(domain || "")
37
+ .trim()
38
+ .toLowerCase()
39
+ .slice(0, 253);
40
+ return /^[a-z0-9](?:[a-z0-9.-]*[a-z0-9])?$/.test(normalizedDomain) ? normalizedDomain : "";
41
+ }
42
+
43
+ function createBlockStorage(storage, key, now) {
44
+ const target = safeStorage(storage);
45
+ let loaded = false;
46
+ let currentBlock = null;
47
+
48
+ function read() {
49
+ if (loaded) return currentBlock ? Object.assign({}, currentBlock) : null;
50
+ loaded = true;
51
+ if (!target) return null;
52
+ try {
53
+ const value = JSON.parse(target.getItem(key) || "null");
54
+ if (!value || value.blocked !== true) return null;
55
+ currentBlock = {
56
+ reason: safeReason(value.reason),
57
+ clear: true,
58
+ };
59
+ return Object.assign({}, currentBlock);
60
+ } catch (error) {
61
+ return null;
62
+ }
63
+ }
64
+
65
+ function write(reason) {
66
+ const block = {
67
+ blocked: true,
68
+ reason: safeReason(reason),
69
+ blocked_at: typeof now === "function" ? now() : Date.now(),
70
+ };
71
+ loaded = true;
72
+ currentBlock = { reason: block.reason, clear: true };
73
+ if (target) {
74
+ try {
75
+ target.setItem(key, JSON.stringify(block));
76
+ } catch (error) {
77
+ // Privacy controls remain effective in memory when storage is unavailable.
78
+ }
79
+ }
80
+ return Object.assign({}, currentBlock);
81
+ }
82
+
83
+ function clear() {
84
+ loaded = true;
85
+ currentBlock = null;
86
+ if (!target) return;
87
+ try {
88
+ target.removeItem(key);
89
+ } catch (error) {
90
+ // Best effort only. The current queue is still explicitly unblocked below.
91
+ }
92
+ }
93
+
94
+ return { clear, read, write };
95
+ }
96
+
97
+ function normalizeTrafficPermission(value) {
98
+ if (value === true) return { known: true, allow: true };
99
+ if (value === false) {
100
+ return { known: true, allow: false, reason: "traffic_disabled" };
101
+ }
102
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
103
+ return { known: false, allow: false };
104
+ }
105
+
106
+ const source = value.data && typeof value.data === "object" ? value.data : value;
107
+ const rawBlock = source.blocked || source.block;
108
+ const blockReason = typeof rawBlock === "string" ? rawBlock : rawBlock && (rawBlock.reason || rawBlock.code);
109
+ if (blockReason || source.is_robot === true) {
110
+ return {
111
+ known: true,
112
+ allow: false,
113
+ reason: safeReason(blockReason || "robot"),
114
+ };
115
+ }
116
+
117
+ const allowed =
118
+ typeof source.traffic_allowed === "boolean"
119
+ ? source.traffic_allowed
120
+ : typeof source.allowed === "boolean"
121
+ ? source.allowed
122
+ : source.allow;
123
+ if (allowed === true) return { known: true, allow: true };
124
+ if (allowed === false) {
125
+ return {
126
+ known: true,
127
+ allow: false,
128
+ reason: safeReason(
129
+ source.collection_blocked_reason || source.block_reason || source.reason || "traffic_disabled"
130
+ ),
131
+ };
132
+ }
133
+ return { known: false, allow: false };
134
+ }
135
+
136
+ /**
137
+ * Creates an inert, headless Traffic collector. Hosts must explicitly call
138
+ * `init()` after their router and heartbeat permission are ready.
139
+ */
140
+ function createJx3boxTrafficAnalytics(options) {
141
+ const settings = options || {};
142
+ const runtime = settings.runtime || (typeof window !== "undefined" ? window : {});
143
+ const now = typeof settings.now === "function" ? settings.now : Date.now;
144
+ const recipientDomain = normalizeRecipientDomain(
145
+ settings.domain || (runtime.location && runtime.location.hostname),
146
+ settings.surface
147
+ );
148
+ const blockStorage = createBlockStorage(
149
+ settings.blockStorage || runtime.localStorage,
150
+ settings.blockStorageKey || DEFAULT_BLOCK_STORAGE_KEY,
151
+ now
152
+ );
153
+ const permissionProvider =
154
+ settings.resolveTrafficPermission !== undefined ? settings.resolveTrafficPermission : settings.trafficAllowed;
155
+ let hasPermissionOverride = false;
156
+ let permissionOverride;
157
+ let client = null;
158
+ let queue = null;
159
+ let routerHandle = null;
160
+ let initPromise = null;
161
+ let initialized = false;
162
+ let destroyed = false;
163
+
164
+ function resolvePermissionInput() {
165
+ if (hasPermissionOverride) return Promise.resolve(permissionOverride);
166
+ if (typeof permissionProvider === "function") {
167
+ try {
168
+ return Promise.resolve(permissionProvider()).catch(function () {
169
+ return undefined;
170
+ });
171
+ } catch (error) {
172
+ return Promise.resolve(undefined);
173
+ }
174
+ }
175
+ return Promise.resolve(permissionProvider);
176
+ }
177
+
178
+ function persistAndBlock(reason) {
179
+ const block = blockStorage.write(reason);
180
+ if (client) client.block(block, { clear: true, cancelInflight: true });
181
+ return block;
182
+ }
183
+
184
+ function applyPermission(value) {
185
+ const permission = normalizeTrafficPermission(value);
186
+ if (!permission.known) {
187
+ // Unknown heartbeat/consent state is a transient fail-closed pause.
188
+ // Keep already-sanitized Journal entries for a later explicit allow,
189
+ // but cancel any request that may have started under an older grant.
190
+ if (client) {
191
+ client.block(
192
+ { reason: "traffic_permission_unknown", clear: false },
193
+ {
194
+ clear: false,
195
+ cancelInflight: true,
196
+ }
197
+ );
198
+ }
199
+ return permission;
200
+ }
201
+ if (!permission.allow) {
202
+ persistAndBlock(permission.reason);
203
+ return permission;
204
+ }
205
+ blockStorage.clear();
206
+ if (client) client.unblock();
207
+ return permission;
208
+ }
209
+
210
+ async function guardBeforeFlush(context) {
211
+ const permission = applyPermission(await resolvePermissionInput());
212
+ if (!permission.known) return { allow: false };
213
+ if (!permission.allow) {
214
+ return {
215
+ blocked: { reason: permission.reason, clear: true },
216
+ clear: true,
217
+ cancelInflight: true,
218
+ };
219
+ }
220
+ if (typeof settings.beforeFlush !== "function") return { allow: true };
221
+ const result = await settings.beforeFlush(context);
222
+ if (result && typeof result === "object" && (result.blocked || result.block)) {
223
+ const blocked = normalizeTrafficPermission(result);
224
+ if (!blocked.allow) persistAndBlock(blocked.reason);
225
+ }
226
+ return result;
227
+ }
228
+
229
+ function buildClient() {
230
+ if (!settings.router || typeof settings.router.afterEach !== "function") {
231
+ throw new Error("jx3box traffic analytics requires a Vue Router instance");
232
+ }
233
+ if (!settings.project || !settings.surface || !settings.client || !settings.gameClient) {
234
+ throw new Error("jx3box traffic analytics requires project, surface, client and gameClient");
235
+ }
236
+
237
+ const identity =
238
+ settings.identity ||
239
+ createIdentity({
240
+ runtime,
241
+ now,
242
+ instanceId: settings.instanceId,
243
+ sessionNamespace: settings.sessionNamespace || "analytics",
244
+ sessionTimeoutMs: settings.sessionTimeoutMs,
245
+ });
246
+ const baseAckDecoder = typeof settings.ackDecoder === "function" ? settings.ackDecoder : defaultAckDecoder;
247
+ const trafficSink = createTrafficSink({
248
+ runtime,
249
+ endpoint: settings.batchEndpoint || DEFAULT_BATCH_ENDPOINT,
250
+ fetch: settings.fetch,
251
+ navigator: settings.navigator,
252
+ credentials: settings.credentials,
253
+ headersProvider: settings.headersProvider,
254
+ ackDecoder: function (payload, context) {
255
+ const decoded = baseAckDecoder(payload, context);
256
+ if (decoded && typeof decoded === "object" && (decoded.blocked || decoded.block)) {
257
+ const blocked = normalizeTrafficPermission(decoded);
258
+ if (!blocked.allow) blockStorage.write(blocked.reason);
259
+ }
260
+ return decoded;
261
+ },
262
+ retryPolicy: settings.retryPolicy,
263
+ });
264
+ const storage = createQueueStorage({
265
+ storage: settings.queueStorage || runtime.localStorage,
266
+ key: settings.queueStorageKey || DEFAULT_QUEUE_STORAGE_KEY,
267
+ maxEvents: settings.maxPersistedEvents || 200,
268
+ maxBytes: settings.maxPersistedBytes || 256 * 1024,
269
+ ttlMs: settings.persistTtlMs || 7 * 24 * 60 * 60 * 1000,
270
+ now,
271
+ });
272
+ queue = createEventQueue({
273
+ runtime,
274
+ storage,
275
+ sinks: [trafficSink],
276
+ // A v1 entry has no sink ownership. It belongs to legacy Tracking;
277
+ // since this adapter deliberately has no Tracking sink, it is dropped.
278
+ legacySinkKey: "tracking",
279
+ beforeFlush: guardBeforeFlush,
280
+ batchSize: settings.batchSize || 20,
281
+ maxEvents: settings.maxQueueEvents || 200,
282
+ maxBatchBytes: settings.maxBatchBytes || 60 * 1024,
283
+ flushIntervalMs: settings.flushIntervalMs || 10000,
284
+ maxRetries: settings.maxRetries === undefined ? 5 : settings.maxRetries,
285
+ retryBaseMs: settings.retryBaseMs || 1000,
286
+ now,
287
+ random: settings.random,
288
+ setTimeout: settings.queueSetTimeout,
289
+ clearTimeout: settings.queueClearTimeout,
290
+ onDrop: settings.onDrop,
291
+ });
292
+ const trafficRuleResolver =
293
+ settings.trafficRuleResolver ||
294
+ createRemoteRuleResolver({
295
+ runtime,
296
+ endpoint: settings.configEndpoint || DEFAULT_CONFIG_ENDPOINT,
297
+ fetch: settings.fetch,
298
+ credentials: settings.credentials,
299
+ });
300
+ const compositeRuleResolver = createCompositeRuleResolver({ traffic: trafficRuleResolver });
301
+ const ruleResolver = {
302
+ resolve: async function (input) {
303
+ const resolved = await compositeRuleResolver.resolve(
304
+ Object.assign({}, input || {}, { domain: recipientDomain })
305
+ );
306
+ return resolved ? Object.assign({}, resolved, { domain: recipientDomain }) : null;
307
+ },
308
+ };
309
+ client = createAnalyticsCore({
310
+ runtime,
311
+ identity,
312
+ queue,
313
+ ruleResolver,
314
+ now,
315
+ product: settings.product || "jx3box",
316
+ project: settings.project,
317
+ client: settings.client,
318
+ surface: settings.surface,
319
+ gameClient: settings.gameClient,
320
+ platform: settings.platform,
321
+ channel: settings.channel,
322
+ appVersion: settings.appVersion,
323
+ appBuild: settings.appBuild,
324
+ webVersion: settings.webVersion,
325
+ displayMode: settings.displayMode || "browser",
326
+ sampleSalt: settings.sampleSalt || "jx3box-analytics-v2",
327
+ });
328
+ }
329
+
330
+ async function init() {
331
+ if (destroyed) throw new Error("jx3box traffic analytics has been destroyed");
332
+ if (initialized) return api;
333
+ if (initPromise) return initPromise;
334
+ initPromise = Promise.resolve()
335
+ .then(async function () {
336
+ buildClient();
337
+ const persistedBlock = blockStorage.read();
338
+ if (persistedBlock) client.block(persistedBlock, { clear: true, cancelInflight: true });
339
+ const permission = await resolvePermissionInput();
340
+ if (destroyed) {
341
+ if (client) client.destroy();
342
+ throw new Error("jx3box traffic analytics has been destroyed");
343
+ }
344
+ applyPermission(permission);
345
+ routerHandle = installVueRouterAnalytics(client, settings.router, {
346
+ runtime,
347
+ captureInitial: settings.captureInitial !== false,
348
+ flushBeaconOnPagehide: settings.flushBeaconOnPagehide !== false,
349
+ project: settings.project,
350
+ product: settings.product || "jx3box",
351
+ client: settings.client,
352
+ surface: settings.surface,
353
+ gameClient: settings.gameClient,
354
+ domain: settings.domain,
355
+ });
356
+ initialized = true;
357
+ return api;
358
+ })
359
+ .catch(function (error) {
360
+ if (routerHandle) routerHandle.destroy();
361
+ else if (client) client.destroy();
362
+ routerHandle = null;
363
+ client = null;
364
+ queue = null;
365
+ initPromise = null;
366
+ throw error;
367
+ });
368
+ return initPromise;
369
+ }
370
+
371
+ function setTrafficPermission(value) {
372
+ hasPermissionOverride = true;
373
+ permissionOverride = value;
374
+ if (destroyed) return normalizeTrafficPermission(value);
375
+ return applyPermission(value);
376
+ }
377
+
378
+ function block(reason) {
379
+ return setTrafficPermission({
380
+ traffic_allowed: false,
381
+ collection_blocked_reason: safeReason(reason, "host_block"),
382
+ });
383
+ }
384
+
385
+ function unblock() {
386
+ return setTrafficPermission({ traffic_allowed: true });
387
+ }
388
+
389
+ function getState() {
390
+ return {
391
+ initialized,
392
+ destroyed,
393
+ permission_block: blockStorage.read(),
394
+ analytics: client ? client.getState() : null,
395
+ };
396
+ }
397
+
398
+ function destroy() {
399
+ if (destroyed) return;
400
+ destroyed = true;
401
+ if (routerHandle) routerHandle.destroy();
402
+ if (client) client.destroy();
403
+ routerHandle = null;
404
+ client = null;
405
+ queue = null;
406
+ initialized = false;
407
+ }
408
+
409
+ const api = {
410
+ init,
411
+ destroy,
412
+ block,
413
+ unblock,
414
+ setTrafficPermission,
415
+ getState,
416
+ flush: function (optionsForFlush) {
417
+ return client ? client.flush(optionsForFlush) : Promise.resolve({ sent: 0, pending: 0 });
418
+ },
419
+ flushBeacon: function (optionsForFlush) {
420
+ return client ? client.flushBeacon(optionsForFlush) : false;
421
+ },
422
+ finalize: function (optionsForFinalize) {
423
+ return client ? client.finalizePage(optionsForFinalize) : false;
424
+ },
425
+ clear: function (reason) {
426
+ return client ? client.clear(reason || "host_clear") : 0;
427
+ },
428
+ cancelInflight: function (reason) {
429
+ return client ? client.cancelInflight(reason || "host_cancel") : 0;
430
+ },
431
+ };
432
+ return api;
433
+ }
434
+
435
+ export {
436
+ DEFAULT_BATCH_ENDPOINT,
437
+ DEFAULT_BLOCK_STORAGE_KEY,
438
+ DEFAULT_CONFIG_ENDPOINT,
439
+ DEFAULT_QUEUE_STORAGE_KEY,
440
+ createJx3boxTrafficAnalytics,
441
+ normalizeRecipientDomain,
442
+ normalizeTrafficPermission,
443
+ };