@windrun-huaiin/backend-core 14.1.0 → 14.1.1

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.
@@ -1 +1 @@
1
- {"version":3,"file":"route.d.ts","sourceRoot":"","sources":["../../../../../../src/app/api/user/anonymous/init/route.ts"],"names":[],"mappings":"AAWA,OAAO,EAAE,WAAW,EAAE,YAAY,EAAE,MAAM,aAAa,CAAC;AA0QxD;;;GAGG;AACH,wBAAsB,IAAI,CAAC,OAAO,EAAE,WAAW,kCAE9C"}
1
+ {"version":3,"file":"route.d.ts","sourceRoot":"","sources":["../../../../../../src/app/api/user/anonymous/init/route.ts"],"names":[],"mappings":"AAWA,OAAO,EAAE,WAAW,EAAE,YAAY,EAAE,MAAM,aAAa,CAAC;AAgoBxD;;;GAGG;AACH,wBAAsB,IAAI,CAAC,OAAO,EAAE,WAAW,kCAE9C"}
@@ -25,6 +25,9 @@ function createErrorResponse(message, status = 400) {
25
25
  }
26
26
  const SOURCE_REF_MAX_LENGTH = 2048;
27
27
  const QUERY_PARAM_MAX_LENGTH = 512;
28
+ const USER_AGENT_MAX_LENGTH = 1024;
29
+ const FIRST_TOUCH_HEADER_MAX_LENGTH = 4096;
30
+ const FIRST_TOUCH_HEADER_NAME = 'x-first-touch';
28
31
  function normalizeSourceRef(ref) {
29
32
  if (!ref) {
30
33
  return null;
@@ -49,6 +52,28 @@ function normalizeQueryParam(value) {
49
52
  ? trimmed.slice(0, QUERY_PARAM_MAX_LENGTH)
50
53
  : trimmed;
51
54
  }
55
+ function decodeHeaderValue(value) {
56
+ try {
57
+ return decodeURIComponent(value);
58
+ }
59
+ catch (_a) {
60
+ return null;
61
+ }
62
+ }
63
+ function mergeSourceRef(target, source) {
64
+ if (!source) {
65
+ return;
66
+ }
67
+ const entries = Object.entries(source);
68
+ for (const [key, value] of entries) {
69
+ if (value === undefined || value === null) {
70
+ continue;
71
+ }
72
+ if (target[key] === undefined) {
73
+ target[key] = value;
74
+ }
75
+ }
76
+ }
52
77
  function applySearchParams(sourceRef, params) {
53
78
  const setIfEmpty = (key, value) => {
54
79
  if (sourceRef[key] !== undefined) {
@@ -64,26 +89,309 @@ function applySearchParams(sourceRef, params) {
64
89
  setIfEmpty('utmCampaign', params.get('utm_campaign'));
65
90
  setIfEmpty('utmTerm', params.get('utm_term'));
66
91
  setIfEmpty('utmContent', params.get('utm_content'));
92
+ setIfEmpty('utmId', params.get('utm_id'));
67
93
  setIfEmpty('ref', params.get('ref'));
94
+ setIfEmpty('gclid', params.get('gclid'));
95
+ setIfEmpty('fbclid', params.get('fbclid'));
96
+ setIfEmpty('msclkid', params.get('msclkid'));
97
+ setIfEmpty('ttclid', params.get('ttclid'));
98
+ setIfEmpty('twclid', params.get('twclid'));
99
+ setIfEmpty('liFatId', params.get('li_fat_id'));
100
+ }
101
+ function normalizeHost(host) {
102
+ if (!host) {
103
+ return null;
104
+ }
105
+ return host.trim().toLowerCase() || null;
106
+ }
107
+ function getRootDomain(host) {
108
+ const normalizedHost = normalizeHost(host);
109
+ if (!normalizedHost) {
110
+ return null;
111
+ }
112
+ const hostname = normalizedHost.split(':')[0];
113
+ if (hostname === 'localhost' || /^\d{1,3}(\.\d{1,3}){3}$/.test(hostname)) {
114
+ return hostname;
115
+ }
116
+ const parts = hostname.split('.').filter(Boolean);
117
+ if (parts.length <= 2) {
118
+ return hostname;
119
+ }
120
+ return parts.slice(-2).join('.');
121
+ }
122
+ function isInternalReferer(landingHost, refererHost) {
123
+ const normalizedLandingHost = normalizeHost(landingHost);
124
+ const normalizedRefererHost = normalizeHost(refererHost);
125
+ if (!normalizedLandingHost || !normalizedRefererHost) {
126
+ return false;
127
+ }
128
+ if (normalizedLandingHost === normalizedRefererHost) {
129
+ return true;
130
+ }
131
+ return normalizedLandingHost.endsWith(`.${normalizedRefererHost}`)
132
+ || normalizedRefererHost.endsWith(`.${normalizedLandingHost}`);
133
+ }
134
+ function detectPlatform(value) {
135
+ const normalized = value === null || value === void 0 ? void 0 : value.trim().toLowerCase();
136
+ if (!normalized) {
137
+ return null;
138
+ }
139
+ const matcherList = [
140
+ { pattern: /chatgpt|chat-openai|openai/, platform: 'openai', channel: 'ai' },
141
+ { pattern: /claude|anthropic/, platform: 'anthropic', channel: 'ai' },
142
+ { pattern: /perplexity/, platform: 'perplexity', channel: 'ai' },
143
+ { pattern: /gemini/, platform: 'gemini', channel: 'ai' },
144
+ { pattern: /copilot/, platform: 'copilot', channel: 'ai' },
145
+ { pattern: /google/, platform: 'google', channel: 'search' },
146
+ { pattern: /bing/, platform: 'bing', channel: 'search' },
147
+ { pattern: /baidu/, platform: 'baidu', channel: 'search' },
148
+ { pattern: /yahoo/, platform: 'yahoo', channel: 'search' },
149
+ { pattern: /duckduckgo/, platform: 'duckduckgo', channel: 'search' },
150
+ { pattern: /facebook/, platform: 'facebook', channel: 'social' },
151
+ { pattern: /instagram/, platform: 'instagram', channel: 'social' },
152
+ { pattern: /x\.com|twitter/, platform: 'x', channel: 'social' },
153
+ { pattern: /linkedin/, platform: 'linkedin', channel: 'social' },
154
+ { pattern: /reddit/, platform: 'reddit', channel: 'social' },
155
+ { pattern: /youtube/, platform: 'youtube', channel: 'social' },
156
+ ];
157
+ const matched = matcherList.find(({ pattern }) => pattern.test(normalized));
158
+ if (!matched) {
159
+ return null;
160
+ }
161
+ return matched.platform;
162
+ }
163
+ function detectChannelFromPlatform(platform) {
164
+ switch (platform) {
165
+ case 'openai':
166
+ case 'anthropic':
167
+ case 'perplexity':
168
+ case 'gemini':
169
+ case 'copilot':
170
+ return 'ai';
171
+ case 'google':
172
+ case 'bing':
173
+ case 'baidu':
174
+ case 'yahoo':
175
+ case 'duckduckgo':
176
+ return 'search';
177
+ case 'facebook':
178
+ case 'instagram':
179
+ case 'x':
180
+ case 'linkedin':
181
+ case 'reddit':
182
+ case 'youtube':
183
+ return 'social';
184
+ default:
185
+ return null;
186
+ }
187
+ }
188
+ function parseUserAgent(request) {
189
+ var _a, _b, _c, _d, _e;
190
+ const userAgentHeader = request.headers.get('user-agent');
191
+ const secChUaMobile = (_a = normalizeQueryParam(request.headers.get('sec-ch-ua-mobile'))) !== null && _a !== void 0 ? _a : undefined;
192
+ const secChUaPlatform = (_b = normalizeQueryParam(request.headers.get('sec-ch-ua-platform'))) !== null && _b !== void 0 ? _b : undefined;
193
+ const userAgent = (_d = (_c = normalizeSourceRef(userAgentHeader)) === null || _c === void 0 ? void 0 : _c.slice(0, USER_AGENT_MAX_LENGTH)) !== null && _d !== void 0 ? _d : undefined;
194
+ const ua = (_e = userAgent === null || userAgent === void 0 ? void 0 : userAgent.toLowerCase()) !== null && _e !== void 0 ? _e : '';
195
+ let deviceType = 'desktop';
196
+ if (!ua) {
197
+ deviceType = 'unknown';
198
+ }
199
+ else if (/bot|spider|crawler|curl|wget|headless/.test(ua)) {
200
+ deviceType = 'bot';
201
+ }
202
+ else if (/ipad|tablet/.test(ua)) {
203
+ deviceType = 'tablet';
204
+ }
205
+ else if (/mobi|iphone|android/.test(ua) || secChUaMobile === '?1') {
206
+ deviceType = 'mobile';
207
+ }
208
+ let os = 'Unknown';
209
+ if (/iphone|ipad|ipod/.test(ua)) {
210
+ os = 'iOS';
211
+ }
212
+ else if (/android/.test(ua)) {
213
+ os = 'Android';
214
+ }
215
+ else if (/windows nt/.test(ua)) {
216
+ os = 'Windows';
217
+ }
218
+ else if (/mac os x|macintosh/.test(ua)) {
219
+ os = 'macOS';
220
+ }
221
+ else if (/cros/.test(ua)) {
222
+ os = 'Chrome OS';
223
+ }
224
+ else if (/linux/.test(ua)) {
225
+ os = 'Linux';
226
+ }
227
+ if (secChUaPlatform) {
228
+ const normalizedPlatform = secChUaPlatform.replaceAll('"', '');
229
+ if (normalizedPlatform && normalizedPlatform !== 'Unknown') {
230
+ os = normalizedPlatform;
231
+ }
232
+ }
233
+ let browser = 'Unknown';
234
+ if (/edg\//.test(ua)) {
235
+ browser = 'Edge';
236
+ }
237
+ else if (/opr\//.test(ua) || /opera/.test(ua)) {
238
+ browser = 'Opera';
239
+ }
240
+ else if (/samsungbrowser\//.test(ua)) {
241
+ browser = 'Samsung Internet';
242
+ }
243
+ else if (/crios\//.test(ua) || /chrome\//.test(ua)) {
244
+ browser = 'Chrome';
245
+ }
246
+ else if (/firefox\//.test(ua)) {
247
+ browser = 'Firefox';
248
+ }
249
+ else if (/safari\//.test(ua) && !/chrome\//.test(ua) && !/crios\//.test(ua)) {
250
+ browser = 'Safari';
251
+ }
252
+ return {
253
+ userAgent,
254
+ deviceType,
255
+ os,
256
+ browser,
257
+ secChUaMobile,
258
+ secChUaPlatform,
259
+ };
260
+ }
261
+ function parseFirstTouchHeader(request) {
262
+ var _a, _b, _c, _d, _e, _f, _g, _h, _j, _k, _l, _m, _o, _p, _q, _r, _s, _t, _u, _v, _w;
263
+ const rawHeader = request.headers.get(FIRST_TOUCH_HEADER_NAME);
264
+ const normalizedHeader = (_a = normalizeSourceRef(rawHeader)) === null || _a === void 0 ? void 0 : _a.slice(0, FIRST_TOUCH_HEADER_MAX_LENGTH);
265
+ if (!normalizedHeader) {
266
+ return null;
267
+ }
268
+ const decodedHeader = decodeHeaderValue(normalizedHeader);
269
+ if (!decodedHeader) {
270
+ return null;
271
+ }
272
+ try {
273
+ const parsed = JSON.parse(decodedHeader);
274
+ const sourceRef = {};
275
+ sourceRef.capturedAt = (_b = normalizeQueryParam(typeof parsed.capturedAt === 'string' ? parsed.capturedAt : null)) !== null && _b !== void 0 ? _b : undefined;
276
+ sourceRef.landingUrl = (_c = normalizeSourceRef(typeof parsed.landingUrl === 'string' ? parsed.landingUrl : null)) !== null && _c !== void 0 ? _c : undefined;
277
+ sourceRef.landingPath = (_d = normalizeSourceRef(typeof parsed.landingPath === 'string' ? parsed.landingPath : null)) !== null && _d !== void 0 ? _d : undefined;
278
+ sourceRef.landingHost = (_e = normalizeHost(typeof parsed.landingHost === 'string' ? parsed.landingHost : null)) !== null && _e !== void 0 ? _e : undefined;
279
+ sourceRef.ref = (_f = normalizeQueryParam(typeof parsed.ref === 'string' ? parsed.ref : null)) !== null && _f !== void 0 ? _f : undefined;
280
+ sourceRef.utmSource = (_g = normalizeQueryParam(typeof parsed.utmSource === 'string' ? parsed.utmSource : null)) !== null && _g !== void 0 ? _g : undefined;
281
+ sourceRef.utmMedium = (_h = normalizeQueryParam(typeof parsed.utmMedium === 'string' ? parsed.utmMedium : null)) !== null && _h !== void 0 ? _h : undefined;
282
+ sourceRef.utmCampaign = (_j = normalizeQueryParam(typeof parsed.utmCampaign === 'string' ? parsed.utmCampaign : null)) !== null && _j !== void 0 ? _j : undefined;
283
+ sourceRef.utmTerm = (_k = normalizeQueryParam(typeof parsed.utmTerm === 'string' ? parsed.utmTerm : null)) !== null && _k !== void 0 ? _k : undefined;
284
+ sourceRef.utmContent = (_l = normalizeQueryParam(typeof parsed.utmContent === 'string' ? parsed.utmContent : null)) !== null && _l !== void 0 ? _l : undefined;
285
+ sourceRef.utmId = (_m = normalizeQueryParam(typeof parsed.utmId === 'string' ? parsed.utmId : null)) !== null && _m !== void 0 ? _m : undefined;
286
+ sourceRef.gclid = (_o = normalizeQueryParam(typeof parsed.gclid === 'string' ? parsed.gclid : null)) !== null && _o !== void 0 ? _o : undefined;
287
+ sourceRef.fbclid = (_p = normalizeQueryParam(typeof parsed.fbclid === 'string' ? parsed.fbclid : null)) !== null && _p !== void 0 ? _p : undefined;
288
+ sourceRef.msclkid = (_q = normalizeQueryParam(typeof parsed.msclkid === 'string' ? parsed.msclkid : null)) !== null && _q !== void 0 ? _q : undefined;
289
+ sourceRef.ttclid = (_r = normalizeQueryParam(typeof parsed.ttclid === 'string' ? parsed.ttclid : null)) !== null && _r !== void 0 ? _r : undefined;
290
+ sourceRef.twclid = (_s = normalizeQueryParam(typeof parsed.twclid === 'string' ? parsed.twclid : null)) !== null && _s !== void 0 ? _s : undefined;
291
+ sourceRef.liFatId = (_t = normalizeQueryParam(typeof parsed.liFatId === 'string' ? parsed.liFatId : null)) !== null && _t !== void 0 ? _t : undefined;
292
+ const externalReferrer = normalizeSourceRef(typeof parsed.externalReferrer === 'string' ? parsed.externalReferrer : null);
293
+ if (externalReferrer) {
294
+ sourceRef.httpRefer = externalReferrer;
295
+ try {
296
+ const refererUrl = new URL(externalReferrer);
297
+ sourceRef.refererHost = (_u = normalizeHost(refererUrl.host)) !== null && _u !== void 0 ? _u : undefined;
298
+ sourceRef.refererPath = (_v = normalizeSourceRef(refererUrl.pathname)) !== null && _v !== void 0 ? _v : undefined;
299
+ sourceRef.refererDomain = (_w = getRootDomain(refererUrl.host)) !== null && _w !== void 0 ? _w : undefined;
300
+ applySearchParams(sourceRef, refererUrl.searchParams);
301
+ }
302
+ catch (error) {
303
+ console.warn('Failed to parse first-touch referrer url:', error);
304
+ }
305
+ }
306
+ return Object.keys(sourceRef).length > 0 ? sourceRef : null;
307
+ }
308
+ catch (error) {
309
+ console.warn('Failed to parse first-touch header:', error);
310
+ return null;
311
+ }
312
+ }
313
+ function finalizeAttribution(sourceRef) {
314
+ var _a, _b, _c, _d;
315
+ const landingHost = normalizeHost(sourceRef.landingHost);
316
+ const refererHost = normalizeHost(sourceRef.refererHost);
317
+ const internal = isInternalReferer(landingHost, refererHost);
318
+ if (internal) {
319
+ sourceRef.isInternalReferer = true;
320
+ }
321
+ const utmPlatform = detectPlatform(sourceRef.utmSource) || detectPlatform(sourceRef.ref);
322
+ if (utmPlatform) {
323
+ sourceRef.sourcePlatform = utmPlatform;
324
+ sourceRef.sourceChannel = (_b = (_a = detectChannelFromPlatform(utmPlatform)) !== null && _a !== void 0 ? _a : sourceRef.sourceChannel) !== null && _b !== void 0 ? _b : 'campaign';
325
+ sourceRef.sourceType = 'campaign';
326
+ return;
327
+ }
328
+ if (sourceRef.gclid) {
329
+ sourceRef.sourcePlatform = 'google';
330
+ sourceRef.sourceChannel = 'search';
331
+ sourceRef.sourceType = 'campaign';
332
+ return;
333
+ }
334
+ if (sourceRef.msclkid) {
335
+ sourceRef.sourcePlatform = 'bing';
336
+ sourceRef.sourceChannel = 'search';
337
+ sourceRef.sourceType = 'campaign';
338
+ return;
339
+ }
340
+ if (sourceRef.fbclid) {
341
+ sourceRef.sourcePlatform = 'facebook';
342
+ sourceRef.sourceChannel = 'social';
343
+ sourceRef.sourceType = 'campaign';
344
+ return;
345
+ }
346
+ if (sourceRef.ttclid) {
347
+ sourceRef.sourcePlatform = 'tiktok';
348
+ sourceRef.sourceChannel = 'social';
349
+ sourceRef.sourceType = 'campaign';
350
+ return;
351
+ }
352
+ if (sourceRef.twclid) {
353
+ sourceRef.sourcePlatform = 'x';
354
+ sourceRef.sourceChannel = 'social';
355
+ sourceRef.sourceType = 'campaign';
356
+ return;
357
+ }
358
+ if (sourceRef.liFatId) {
359
+ sourceRef.sourcePlatform = 'linkedin';
360
+ sourceRef.sourceChannel = 'social';
361
+ sourceRef.sourceType = 'campaign';
362
+ return;
363
+ }
364
+ if (!internal && refererHost) {
365
+ const refererPlatform = detectPlatform(refererHost) || detectPlatform(sourceRef.httpRefer);
366
+ sourceRef.sourcePlatform = (_c = refererPlatform !== null && refererPlatform !== void 0 ? refererPlatform : getRootDomain(refererHost)) !== null && _c !== void 0 ? _c : refererHost;
367
+ sourceRef.sourceChannel = (_d = detectChannelFromPlatform(refererPlatform)) !== null && _d !== void 0 ? _d : 'referral';
368
+ sourceRef.sourceType = 'referer';
369
+ return;
370
+ }
371
+ sourceRef.sourcePlatform = 'direct';
372
+ sourceRef.sourceChannel = 'direct';
373
+ sourceRef.sourceType = 'direct';
68
374
  }
69
375
  // 提取用户首次访问来源
70
376
  function extractSourceRef(request) {
377
+ var _a, _b, _c, _d, _e, _f, _g, _h, _j, _k, _l, _m, _o, _p, _q;
71
378
  const headerRef = request.headers.get('referer') || request.headers.get('referrer');
72
379
  const customRef = request.headers.get('x-source-ref');
73
380
  const queryRef = request.nextUrl.searchParams.get('ref');
74
- console.log({
75
- headerRef,
76
- customRef,
77
- queryRef
78
- });
79
- const sourceRef = {};
381
+ const firstTouchRef = parseFirstTouchHeader(request);
382
+ const sourceRef = Object.assign({}, parseUserAgent(request));
383
+ mergeSourceRef(sourceRef, firstTouchRef);
384
+ sourceRef.landingUrl = (_b = (_a = sourceRef.landingUrl) !== null && _a !== void 0 ? _a : normalizeSourceRef(request.nextUrl.toString())) !== null && _b !== void 0 ? _b : undefined;
385
+ sourceRef.landingPath = (_d = (_c = sourceRef.landingPath) !== null && _c !== void 0 ? _c : normalizeSourceRef(request.nextUrl.pathname)) !== null && _d !== void 0 ? _d : undefined;
386
+ sourceRef.landingHost = (_f = (_e = sourceRef.landingHost) !== null && _e !== void 0 ? _e : normalizeHost(request.nextUrl.host)) !== null && _f !== void 0 ? _f : undefined;
387
+ sourceRef.ref = (_h = (_g = sourceRef.ref) !== null && _g !== void 0 ? _g : normalizeQueryParam(queryRef)) !== null && _h !== void 0 ? _h : undefined;
80
388
  let normalizedHttpRef = null;
81
- const candidates = [headerRef, customRef, queryRef];
389
+ const candidates = [customRef, headerRef];
82
390
  for (const candidate of candidates) {
83
391
  const normalized = normalizeSourceRef(candidate);
84
392
  if (normalized) {
85
393
  normalizedHttpRef = normalized;
86
- sourceRef.httpRefer = normalized;
394
+ sourceRef.httpRefer = (_j = sourceRef.httpRefer) !== null && _j !== void 0 ? _j : normalized;
87
395
  break;
88
396
  }
89
397
  }
@@ -92,12 +400,16 @@ function extractSourceRef(request) {
92
400
  if (normalizedHttpRef) {
93
401
  try {
94
402
  const refererUrl = new URL(normalizedHttpRef);
403
+ sourceRef.refererHost = (_l = (_k = sourceRef.refererHost) !== null && _k !== void 0 ? _k : normalizeHost(refererUrl.host)) !== null && _l !== void 0 ? _l : undefined;
404
+ sourceRef.refererPath = (_o = (_m = sourceRef.refererPath) !== null && _m !== void 0 ? _m : normalizeSourceRef(refererUrl.pathname)) !== null && _o !== void 0 ? _o : undefined;
405
+ sourceRef.refererDomain = (_q = (_p = sourceRef.refererDomain) !== null && _p !== void 0 ? _p : getRootDomain(refererUrl.host)) !== null && _q !== void 0 ? _q : undefined;
95
406
  applySearchParams(sourceRef, refererUrl.searchParams);
96
407
  }
97
408
  catch (error) {
98
409
  console.warn('Failed to parse referer url for utm/ref:', error);
99
410
  }
100
411
  }
412
+ finalizeAttribution(sourceRef);
101
413
  return Object.keys(sourceRef).length > 0 ? sourceRef : null;
102
414
  }
103
415
  /**
@@ -23,6 +23,9 @@ function createErrorResponse(message, status = 400) {
23
23
  }
24
24
  const SOURCE_REF_MAX_LENGTH = 2048;
25
25
  const QUERY_PARAM_MAX_LENGTH = 512;
26
+ const USER_AGENT_MAX_LENGTH = 1024;
27
+ const FIRST_TOUCH_HEADER_MAX_LENGTH = 4096;
28
+ const FIRST_TOUCH_HEADER_NAME = 'x-first-touch';
26
29
  function normalizeSourceRef(ref) {
27
30
  if (!ref) {
28
31
  return null;
@@ -47,6 +50,28 @@ function normalizeQueryParam(value) {
47
50
  ? trimmed.slice(0, QUERY_PARAM_MAX_LENGTH)
48
51
  : trimmed;
49
52
  }
53
+ function decodeHeaderValue(value) {
54
+ try {
55
+ return decodeURIComponent(value);
56
+ }
57
+ catch (_a) {
58
+ return null;
59
+ }
60
+ }
61
+ function mergeSourceRef(target, source) {
62
+ if (!source) {
63
+ return;
64
+ }
65
+ const entries = Object.entries(source);
66
+ for (const [key, value] of entries) {
67
+ if (value === undefined || value === null) {
68
+ continue;
69
+ }
70
+ if (target[key] === undefined) {
71
+ target[key] = value;
72
+ }
73
+ }
74
+ }
50
75
  function applySearchParams(sourceRef, params) {
51
76
  const setIfEmpty = (key, value) => {
52
77
  if (sourceRef[key] !== undefined) {
@@ -62,26 +87,309 @@ function applySearchParams(sourceRef, params) {
62
87
  setIfEmpty('utmCampaign', params.get('utm_campaign'));
63
88
  setIfEmpty('utmTerm', params.get('utm_term'));
64
89
  setIfEmpty('utmContent', params.get('utm_content'));
90
+ setIfEmpty('utmId', params.get('utm_id'));
65
91
  setIfEmpty('ref', params.get('ref'));
92
+ setIfEmpty('gclid', params.get('gclid'));
93
+ setIfEmpty('fbclid', params.get('fbclid'));
94
+ setIfEmpty('msclkid', params.get('msclkid'));
95
+ setIfEmpty('ttclid', params.get('ttclid'));
96
+ setIfEmpty('twclid', params.get('twclid'));
97
+ setIfEmpty('liFatId', params.get('li_fat_id'));
98
+ }
99
+ function normalizeHost(host) {
100
+ if (!host) {
101
+ return null;
102
+ }
103
+ return host.trim().toLowerCase() || null;
104
+ }
105
+ function getRootDomain(host) {
106
+ const normalizedHost = normalizeHost(host);
107
+ if (!normalizedHost) {
108
+ return null;
109
+ }
110
+ const hostname = normalizedHost.split(':')[0];
111
+ if (hostname === 'localhost' || /^\d{1,3}(\.\d{1,3}){3}$/.test(hostname)) {
112
+ return hostname;
113
+ }
114
+ const parts = hostname.split('.').filter(Boolean);
115
+ if (parts.length <= 2) {
116
+ return hostname;
117
+ }
118
+ return parts.slice(-2).join('.');
119
+ }
120
+ function isInternalReferer(landingHost, refererHost) {
121
+ const normalizedLandingHost = normalizeHost(landingHost);
122
+ const normalizedRefererHost = normalizeHost(refererHost);
123
+ if (!normalizedLandingHost || !normalizedRefererHost) {
124
+ return false;
125
+ }
126
+ if (normalizedLandingHost === normalizedRefererHost) {
127
+ return true;
128
+ }
129
+ return normalizedLandingHost.endsWith(`.${normalizedRefererHost}`)
130
+ || normalizedRefererHost.endsWith(`.${normalizedLandingHost}`);
131
+ }
132
+ function detectPlatform(value) {
133
+ const normalized = value === null || value === void 0 ? void 0 : value.trim().toLowerCase();
134
+ if (!normalized) {
135
+ return null;
136
+ }
137
+ const matcherList = [
138
+ { pattern: /chatgpt|chat-openai|openai/, platform: 'openai', channel: 'ai' },
139
+ { pattern: /claude|anthropic/, platform: 'anthropic', channel: 'ai' },
140
+ { pattern: /perplexity/, platform: 'perplexity', channel: 'ai' },
141
+ { pattern: /gemini/, platform: 'gemini', channel: 'ai' },
142
+ { pattern: /copilot/, platform: 'copilot', channel: 'ai' },
143
+ { pattern: /google/, platform: 'google', channel: 'search' },
144
+ { pattern: /bing/, platform: 'bing', channel: 'search' },
145
+ { pattern: /baidu/, platform: 'baidu', channel: 'search' },
146
+ { pattern: /yahoo/, platform: 'yahoo', channel: 'search' },
147
+ { pattern: /duckduckgo/, platform: 'duckduckgo', channel: 'search' },
148
+ { pattern: /facebook/, platform: 'facebook', channel: 'social' },
149
+ { pattern: /instagram/, platform: 'instagram', channel: 'social' },
150
+ { pattern: /x\.com|twitter/, platform: 'x', channel: 'social' },
151
+ { pattern: /linkedin/, platform: 'linkedin', channel: 'social' },
152
+ { pattern: /reddit/, platform: 'reddit', channel: 'social' },
153
+ { pattern: /youtube/, platform: 'youtube', channel: 'social' },
154
+ ];
155
+ const matched = matcherList.find(({ pattern }) => pattern.test(normalized));
156
+ if (!matched) {
157
+ return null;
158
+ }
159
+ return matched.platform;
160
+ }
161
+ function detectChannelFromPlatform(platform) {
162
+ switch (platform) {
163
+ case 'openai':
164
+ case 'anthropic':
165
+ case 'perplexity':
166
+ case 'gemini':
167
+ case 'copilot':
168
+ return 'ai';
169
+ case 'google':
170
+ case 'bing':
171
+ case 'baidu':
172
+ case 'yahoo':
173
+ case 'duckduckgo':
174
+ return 'search';
175
+ case 'facebook':
176
+ case 'instagram':
177
+ case 'x':
178
+ case 'linkedin':
179
+ case 'reddit':
180
+ case 'youtube':
181
+ return 'social';
182
+ default:
183
+ return null;
184
+ }
185
+ }
186
+ function parseUserAgent(request) {
187
+ var _a, _b, _c, _d, _e;
188
+ const userAgentHeader = request.headers.get('user-agent');
189
+ const secChUaMobile = (_a = normalizeQueryParam(request.headers.get('sec-ch-ua-mobile'))) !== null && _a !== void 0 ? _a : undefined;
190
+ const secChUaPlatform = (_b = normalizeQueryParam(request.headers.get('sec-ch-ua-platform'))) !== null && _b !== void 0 ? _b : undefined;
191
+ const userAgent = (_d = (_c = normalizeSourceRef(userAgentHeader)) === null || _c === void 0 ? void 0 : _c.slice(0, USER_AGENT_MAX_LENGTH)) !== null && _d !== void 0 ? _d : undefined;
192
+ const ua = (_e = userAgent === null || userAgent === void 0 ? void 0 : userAgent.toLowerCase()) !== null && _e !== void 0 ? _e : '';
193
+ let deviceType = 'desktop';
194
+ if (!ua) {
195
+ deviceType = 'unknown';
196
+ }
197
+ else if (/bot|spider|crawler|curl|wget|headless/.test(ua)) {
198
+ deviceType = 'bot';
199
+ }
200
+ else if (/ipad|tablet/.test(ua)) {
201
+ deviceType = 'tablet';
202
+ }
203
+ else if (/mobi|iphone|android/.test(ua) || secChUaMobile === '?1') {
204
+ deviceType = 'mobile';
205
+ }
206
+ let os = 'Unknown';
207
+ if (/iphone|ipad|ipod/.test(ua)) {
208
+ os = 'iOS';
209
+ }
210
+ else if (/android/.test(ua)) {
211
+ os = 'Android';
212
+ }
213
+ else if (/windows nt/.test(ua)) {
214
+ os = 'Windows';
215
+ }
216
+ else if (/mac os x|macintosh/.test(ua)) {
217
+ os = 'macOS';
218
+ }
219
+ else if (/cros/.test(ua)) {
220
+ os = 'Chrome OS';
221
+ }
222
+ else if (/linux/.test(ua)) {
223
+ os = 'Linux';
224
+ }
225
+ if (secChUaPlatform) {
226
+ const normalizedPlatform = secChUaPlatform.replaceAll('"', '');
227
+ if (normalizedPlatform && normalizedPlatform !== 'Unknown') {
228
+ os = normalizedPlatform;
229
+ }
230
+ }
231
+ let browser = 'Unknown';
232
+ if (/edg\//.test(ua)) {
233
+ browser = 'Edge';
234
+ }
235
+ else if (/opr\//.test(ua) || /opera/.test(ua)) {
236
+ browser = 'Opera';
237
+ }
238
+ else if (/samsungbrowser\//.test(ua)) {
239
+ browser = 'Samsung Internet';
240
+ }
241
+ else if (/crios\//.test(ua) || /chrome\//.test(ua)) {
242
+ browser = 'Chrome';
243
+ }
244
+ else if (/firefox\//.test(ua)) {
245
+ browser = 'Firefox';
246
+ }
247
+ else if (/safari\//.test(ua) && !/chrome\//.test(ua) && !/crios\//.test(ua)) {
248
+ browser = 'Safari';
249
+ }
250
+ return {
251
+ userAgent,
252
+ deviceType,
253
+ os,
254
+ browser,
255
+ secChUaMobile,
256
+ secChUaPlatform,
257
+ };
258
+ }
259
+ function parseFirstTouchHeader(request) {
260
+ var _a, _b, _c, _d, _e, _f, _g, _h, _j, _k, _l, _m, _o, _p, _q, _r, _s, _t, _u, _v, _w;
261
+ const rawHeader = request.headers.get(FIRST_TOUCH_HEADER_NAME);
262
+ const normalizedHeader = (_a = normalizeSourceRef(rawHeader)) === null || _a === void 0 ? void 0 : _a.slice(0, FIRST_TOUCH_HEADER_MAX_LENGTH);
263
+ if (!normalizedHeader) {
264
+ return null;
265
+ }
266
+ const decodedHeader = decodeHeaderValue(normalizedHeader);
267
+ if (!decodedHeader) {
268
+ return null;
269
+ }
270
+ try {
271
+ const parsed = JSON.parse(decodedHeader);
272
+ const sourceRef = {};
273
+ sourceRef.capturedAt = (_b = normalizeQueryParam(typeof parsed.capturedAt === 'string' ? parsed.capturedAt : null)) !== null && _b !== void 0 ? _b : undefined;
274
+ sourceRef.landingUrl = (_c = normalizeSourceRef(typeof parsed.landingUrl === 'string' ? parsed.landingUrl : null)) !== null && _c !== void 0 ? _c : undefined;
275
+ sourceRef.landingPath = (_d = normalizeSourceRef(typeof parsed.landingPath === 'string' ? parsed.landingPath : null)) !== null && _d !== void 0 ? _d : undefined;
276
+ sourceRef.landingHost = (_e = normalizeHost(typeof parsed.landingHost === 'string' ? parsed.landingHost : null)) !== null && _e !== void 0 ? _e : undefined;
277
+ sourceRef.ref = (_f = normalizeQueryParam(typeof parsed.ref === 'string' ? parsed.ref : null)) !== null && _f !== void 0 ? _f : undefined;
278
+ sourceRef.utmSource = (_g = normalizeQueryParam(typeof parsed.utmSource === 'string' ? parsed.utmSource : null)) !== null && _g !== void 0 ? _g : undefined;
279
+ sourceRef.utmMedium = (_h = normalizeQueryParam(typeof parsed.utmMedium === 'string' ? parsed.utmMedium : null)) !== null && _h !== void 0 ? _h : undefined;
280
+ sourceRef.utmCampaign = (_j = normalizeQueryParam(typeof parsed.utmCampaign === 'string' ? parsed.utmCampaign : null)) !== null && _j !== void 0 ? _j : undefined;
281
+ sourceRef.utmTerm = (_k = normalizeQueryParam(typeof parsed.utmTerm === 'string' ? parsed.utmTerm : null)) !== null && _k !== void 0 ? _k : undefined;
282
+ sourceRef.utmContent = (_l = normalizeQueryParam(typeof parsed.utmContent === 'string' ? parsed.utmContent : null)) !== null && _l !== void 0 ? _l : undefined;
283
+ sourceRef.utmId = (_m = normalizeQueryParam(typeof parsed.utmId === 'string' ? parsed.utmId : null)) !== null && _m !== void 0 ? _m : undefined;
284
+ sourceRef.gclid = (_o = normalizeQueryParam(typeof parsed.gclid === 'string' ? parsed.gclid : null)) !== null && _o !== void 0 ? _o : undefined;
285
+ sourceRef.fbclid = (_p = normalizeQueryParam(typeof parsed.fbclid === 'string' ? parsed.fbclid : null)) !== null && _p !== void 0 ? _p : undefined;
286
+ sourceRef.msclkid = (_q = normalizeQueryParam(typeof parsed.msclkid === 'string' ? parsed.msclkid : null)) !== null && _q !== void 0 ? _q : undefined;
287
+ sourceRef.ttclid = (_r = normalizeQueryParam(typeof parsed.ttclid === 'string' ? parsed.ttclid : null)) !== null && _r !== void 0 ? _r : undefined;
288
+ sourceRef.twclid = (_s = normalizeQueryParam(typeof parsed.twclid === 'string' ? parsed.twclid : null)) !== null && _s !== void 0 ? _s : undefined;
289
+ sourceRef.liFatId = (_t = normalizeQueryParam(typeof parsed.liFatId === 'string' ? parsed.liFatId : null)) !== null && _t !== void 0 ? _t : undefined;
290
+ const externalReferrer = normalizeSourceRef(typeof parsed.externalReferrer === 'string' ? parsed.externalReferrer : null);
291
+ if (externalReferrer) {
292
+ sourceRef.httpRefer = externalReferrer;
293
+ try {
294
+ const refererUrl = new URL(externalReferrer);
295
+ sourceRef.refererHost = (_u = normalizeHost(refererUrl.host)) !== null && _u !== void 0 ? _u : undefined;
296
+ sourceRef.refererPath = (_v = normalizeSourceRef(refererUrl.pathname)) !== null && _v !== void 0 ? _v : undefined;
297
+ sourceRef.refererDomain = (_w = getRootDomain(refererUrl.host)) !== null && _w !== void 0 ? _w : undefined;
298
+ applySearchParams(sourceRef, refererUrl.searchParams);
299
+ }
300
+ catch (error) {
301
+ console.warn('Failed to parse first-touch referrer url:', error);
302
+ }
303
+ }
304
+ return Object.keys(sourceRef).length > 0 ? sourceRef : null;
305
+ }
306
+ catch (error) {
307
+ console.warn('Failed to parse first-touch header:', error);
308
+ return null;
309
+ }
310
+ }
311
+ function finalizeAttribution(sourceRef) {
312
+ var _a, _b, _c, _d;
313
+ const landingHost = normalizeHost(sourceRef.landingHost);
314
+ const refererHost = normalizeHost(sourceRef.refererHost);
315
+ const internal = isInternalReferer(landingHost, refererHost);
316
+ if (internal) {
317
+ sourceRef.isInternalReferer = true;
318
+ }
319
+ const utmPlatform = detectPlatform(sourceRef.utmSource) || detectPlatform(sourceRef.ref);
320
+ if (utmPlatform) {
321
+ sourceRef.sourcePlatform = utmPlatform;
322
+ sourceRef.sourceChannel = (_b = (_a = detectChannelFromPlatform(utmPlatform)) !== null && _a !== void 0 ? _a : sourceRef.sourceChannel) !== null && _b !== void 0 ? _b : 'campaign';
323
+ sourceRef.sourceType = 'campaign';
324
+ return;
325
+ }
326
+ if (sourceRef.gclid) {
327
+ sourceRef.sourcePlatform = 'google';
328
+ sourceRef.sourceChannel = 'search';
329
+ sourceRef.sourceType = 'campaign';
330
+ return;
331
+ }
332
+ if (sourceRef.msclkid) {
333
+ sourceRef.sourcePlatform = 'bing';
334
+ sourceRef.sourceChannel = 'search';
335
+ sourceRef.sourceType = 'campaign';
336
+ return;
337
+ }
338
+ if (sourceRef.fbclid) {
339
+ sourceRef.sourcePlatform = 'facebook';
340
+ sourceRef.sourceChannel = 'social';
341
+ sourceRef.sourceType = 'campaign';
342
+ return;
343
+ }
344
+ if (sourceRef.ttclid) {
345
+ sourceRef.sourcePlatform = 'tiktok';
346
+ sourceRef.sourceChannel = 'social';
347
+ sourceRef.sourceType = 'campaign';
348
+ return;
349
+ }
350
+ if (sourceRef.twclid) {
351
+ sourceRef.sourcePlatform = 'x';
352
+ sourceRef.sourceChannel = 'social';
353
+ sourceRef.sourceType = 'campaign';
354
+ return;
355
+ }
356
+ if (sourceRef.liFatId) {
357
+ sourceRef.sourcePlatform = 'linkedin';
358
+ sourceRef.sourceChannel = 'social';
359
+ sourceRef.sourceType = 'campaign';
360
+ return;
361
+ }
362
+ if (!internal && refererHost) {
363
+ const refererPlatform = detectPlatform(refererHost) || detectPlatform(sourceRef.httpRefer);
364
+ sourceRef.sourcePlatform = (_c = refererPlatform !== null && refererPlatform !== void 0 ? refererPlatform : getRootDomain(refererHost)) !== null && _c !== void 0 ? _c : refererHost;
365
+ sourceRef.sourceChannel = (_d = detectChannelFromPlatform(refererPlatform)) !== null && _d !== void 0 ? _d : 'referral';
366
+ sourceRef.sourceType = 'referer';
367
+ return;
368
+ }
369
+ sourceRef.sourcePlatform = 'direct';
370
+ sourceRef.sourceChannel = 'direct';
371
+ sourceRef.sourceType = 'direct';
66
372
  }
67
373
  // 提取用户首次访问来源
68
374
  function extractSourceRef(request) {
375
+ var _a, _b, _c, _d, _e, _f, _g, _h, _j, _k, _l, _m, _o, _p, _q;
69
376
  const headerRef = request.headers.get('referer') || request.headers.get('referrer');
70
377
  const customRef = request.headers.get('x-source-ref');
71
378
  const queryRef = request.nextUrl.searchParams.get('ref');
72
- console.log({
73
- headerRef,
74
- customRef,
75
- queryRef
76
- });
77
- const sourceRef = {};
379
+ const firstTouchRef = parseFirstTouchHeader(request);
380
+ const sourceRef = Object.assign({}, parseUserAgent(request));
381
+ mergeSourceRef(sourceRef, firstTouchRef);
382
+ sourceRef.landingUrl = (_b = (_a = sourceRef.landingUrl) !== null && _a !== void 0 ? _a : normalizeSourceRef(request.nextUrl.toString())) !== null && _b !== void 0 ? _b : undefined;
383
+ sourceRef.landingPath = (_d = (_c = sourceRef.landingPath) !== null && _c !== void 0 ? _c : normalizeSourceRef(request.nextUrl.pathname)) !== null && _d !== void 0 ? _d : undefined;
384
+ sourceRef.landingHost = (_f = (_e = sourceRef.landingHost) !== null && _e !== void 0 ? _e : normalizeHost(request.nextUrl.host)) !== null && _f !== void 0 ? _f : undefined;
385
+ sourceRef.ref = (_h = (_g = sourceRef.ref) !== null && _g !== void 0 ? _g : normalizeQueryParam(queryRef)) !== null && _h !== void 0 ? _h : undefined;
78
386
  let normalizedHttpRef = null;
79
- const candidates = [headerRef, customRef, queryRef];
387
+ const candidates = [customRef, headerRef];
80
388
  for (const candidate of candidates) {
81
389
  const normalized = normalizeSourceRef(candidate);
82
390
  if (normalized) {
83
391
  normalizedHttpRef = normalized;
84
- sourceRef.httpRefer = normalized;
392
+ sourceRef.httpRefer = (_j = sourceRef.httpRefer) !== null && _j !== void 0 ? _j : normalized;
85
393
  break;
86
394
  }
87
395
  }
@@ -90,12 +398,16 @@ function extractSourceRef(request) {
90
398
  if (normalizedHttpRef) {
91
399
  try {
92
400
  const refererUrl = new URL(normalizedHttpRef);
401
+ sourceRef.refererHost = (_l = (_k = sourceRef.refererHost) !== null && _k !== void 0 ? _k : normalizeHost(refererUrl.host)) !== null && _l !== void 0 ? _l : undefined;
402
+ sourceRef.refererPath = (_o = (_m = sourceRef.refererPath) !== null && _m !== void 0 ? _m : normalizeSourceRef(refererUrl.pathname)) !== null && _o !== void 0 ? _o : undefined;
403
+ sourceRef.refererDomain = (_q = (_p = sourceRef.refererDomain) !== null && _p !== void 0 ? _p : getRootDomain(refererUrl.host)) !== null && _q !== void 0 ? _q : undefined;
93
404
  applySearchParams(sourceRef, refererUrl.searchParams);
94
405
  }
95
406
  catch (error) {
96
407
  console.warn('Failed to parse referer url for utm/ref:', error);
97
408
  }
98
409
  }
410
+ finalizeAttribution(sourceRef);
99
411
  return Object.keys(sourceRef).length > 0 ? sourceRef : null;
100
412
  }
101
413
  /**
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@windrun-huaiin/backend-core",
3
- "version": "14.1.0",
3
+ "version": "14.1.1",
4
4
  "description": "Shared backend primitives: Prisma schema/client, database services, routing helpers",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -95,7 +95,7 @@
95
95
  "svix": "^1.86.0",
96
96
  "zod": "^4.3.6",
97
97
  "@windrun-huaiin/lib": "^14.0.0",
98
- "@windrun-huaiin/third-ui": "^14.0.0"
98
+ "@windrun-huaiin/third-ui": "^14.0.2"
99
99
  },
100
100
  "devDependencies": {
101
101
  "@rollup/plugin-alias": "^5.1.1",
@@ -6,7 +6,7 @@
6
6
  };
7
7
 
8
8
  import { userAggregateService } from '@/aggregate/user.aggregate.service';
9
- import { XCredit, XSubscription, XUser } from '@windrun-huaiin/third-ui/fingerprint';
9
+ import type { XCredit, XSubscription, XUser } from '@windrun-huaiin/third-ui/fingerprint';
10
10
  import { extractFingerprintFromNextRequest } from '@windrun-huaiin/third-ui/fingerprint/server';
11
11
  import { auth } from '@clerk/nextjs/server';
12
12
  import { NextRequest, NextResponse } from 'next/server';
@@ -70,19 +70,59 @@ function createErrorResponse(message: string, status = 400): NextResponse {
70
70
  }
71
71
 
72
72
  type SourceRefData = Prisma.InputJsonObject & {
73
+ capturedAt?: string;
74
+ landingUrl?: string;
75
+ landingPath?: string;
76
+ landingHost?: string;
73
77
  httpRefer?: string;
78
+ refererHost?: string;
79
+ refererPath?: string;
80
+ refererDomain?: string;
81
+ sourceType?: string;
82
+ sourceChannel?: string;
83
+ sourcePlatform?: string;
84
+ isInternalReferer?: boolean;
74
85
  utmSource?: string;
75
86
  utmMedium?: string;
76
87
  utmCampaign?: string;
77
88
  utmTerm?: string;
78
89
  utmContent?: string;
90
+ utmId?: string;
79
91
  ref?: string;
92
+ gclid?: string;
93
+ fbclid?: string;
94
+ msclkid?: string;
95
+ ttclid?: string;
96
+ twclid?: string;
97
+ liFatId?: string;
98
+ userAgent?: string;
99
+ deviceType?: string;
100
+ os?: string;
101
+ browser?: string;
102
+ secChUaMobile?: string;
103
+ secChUaPlatform?: string;
80
104
  };
81
105
 
82
- type SourceRefKey = 'utmSource' | 'utmMedium' | 'utmCampaign' | 'utmTerm' | 'utmContent' | 'ref';
106
+ type SourceRefKey =
107
+ | 'utmSource'
108
+ | 'utmMedium'
109
+ | 'utmCampaign'
110
+ | 'utmTerm'
111
+ | 'utmContent'
112
+ | 'utmId'
113
+ | 'ref'
114
+ | 'gclid'
115
+ | 'fbclid'
116
+ | 'msclkid'
117
+ | 'ttclid'
118
+ | 'twclid'
119
+ | 'liFatId';
83
120
 
84
121
  const SOURCE_REF_MAX_LENGTH = 2048;
85
122
  const QUERY_PARAM_MAX_LENGTH = 512;
123
+ const USER_AGENT_MAX_LENGTH = 1024;
124
+ const FIRST_TOUCH_HEADER_MAX_LENGTH = 4096;
125
+ const FIRST_TOUCH_HEADER_NAME = 'x-first-touch';
86
126
 
87
127
  function normalizeSourceRef(ref: string | null): string | null {
88
128
  if (!ref) {
@@ -114,6 +154,31 @@ function normalizeQueryParam(value: string | null): string | null {
114
154
  : trimmed;
115
155
  }
116
156
 
157
+ function decodeHeaderValue(value: string): string | null {
158
+ try {
159
+ return decodeURIComponent(value);
160
+ } catch {
161
+ return null;
162
+ }
163
+ }
164
+
165
+ function mergeSourceRef(target: SourceRefData, source: SourceRefData | null | undefined) {
166
+ if (!source) {
167
+ return;
168
+ }
169
+
170
+ const entries = Object.entries(source) as Array<[keyof SourceRefData, SourceRefData[keyof SourceRefData]]>;
171
+ for (const [key, value] of entries) {
172
+ if (value === undefined || value === null) {
173
+ continue;
174
+ }
175
+
176
+ if (target[key] === undefined) {
177
+ (target as Record<string, unknown>)[key as string] = value;
178
+ }
179
+ }
180
+ }
181
+
117
182
  function applySearchParams(sourceRef: SourceRefData, params: URLSearchParams) {
118
183
  const setIfEmpty = (key: SourceRefKey, value: string | null) => {
119
184
  if (sourceRef[key] !== undefined) {
@@ -130,7 +195,306 @@ function applySearchParams(sourceRef: SourceRefData, params: URLSearchParams) {
130
195
  setIfEmpty('utmCampaign', params.get('utm_campaign'));
131
196
  setIfEmpty('utmTerm', params.get('utm_term'));
132
197
  setIfEmpty('utmContent', params.get('utm_content'));
198
+ setIfEmpty('utmId', params.get('utm_id'));
133
199
  setIfEmpty('ref', params.get('ref'));
200
+ setIfEmpty('gclid', params.get('gclid'));
201
+ setIfEmpty('fbclid', params.get('fbclid'));
202
+ setIfEmpty('msclkid', params.get('msclkid'));
203
+ setIfEmpty('ttclid', params.get('ttclid'));
204
+ setIfEmpty('twclid', params.get('twclid'));
205
+ setIfEmpty('liFatId', params.get('li_fat_id'));
206
+ }
207
+
208
+ function normalizeHost(host: string | null | undefined): string | null {
209
+ if (!host) {
210
+ return null;
211
+ }
212
+
213
+ return host.trim().toLowerCase() || null;
214
+ }
215
+
216
+ function getRootDomain(host: string | null | undefined): string | null {
217
+ const normalizedHost = normalizeHost(host);
218
+ if (!normalizedHost) {
219
+ return null;
220
+ }
221
+
222
+ const hostname = normalizedHost.split(':')[0];
223
+ if (hostname === 'localhost' || /^\d{1,3}(\.\d{1,3}){3}$/.test(hostname)) {
224
+ return hostname;
225
+ }
226
+
227
+ const parts = hostname.split('.').filter(Boolean);
228
+ if (parts.length <= 2) {
229
+ return hostname;
230
+ }
231
+
232
+ return parts.slice(-2).join('.');
233
+ }
234
+
235
+ function isInternalReferer(landingHost: string | null | undefined, refererHost: string | null | undefined): boolean {
236
+ const normalizedLandingHost = normalizeHost(landingHost);
237
+ const normalizedRefererHost = normalizeHost(refererHost);
238
+ if (!normalizedLandingHost || !normalizedRefererHost) {
239
+ return false;
240
+ }
241
+
242
+ if (normalizedLandingHost === normalizedRefererHost) {
243
+ return true;
244
+ }
245
+
246
+ return normalizedLandingHost.endsWith(`.${normalizedRefererHost}`)
247
+ || normalizedRefererHost.endsWith(`.${normalizedLandingHost}`);
248
+ }
249
+
250
+ function detectPlatform(value: string | null | undefined): string | null {
251
+ const normalized = value?.trim().toLowerCase();
252
+ if (!normalized) {
253
+ return null;
254
+ }
255
+
256
+ const matcherList: Array<{ pattern: RegExp; platform: string; channel: string; }> = [
257
+ { pattern: /chatgpt|chat-openai|openai/, platform: 'openai', channel: 'ai' },
258
+ { pattern: /claude|anthropic/, platform: 'anthropic', channel: 'ai' },
259
+ { pattern: /perplexity/, platform: 'perplexity', channel: 'ai' },
260
+ { pattern: /gemini/, platform: 'gemini', channel: 'ai' },
261
+ { pattern: /copilot/, platform: 'copilot', channel: 'ai' },
262
+ { pattern: /google/, platform: 'google', channel: 'search' },
263
+ { pattern: /bing/, platform: 'bing', channel: 'search' },
264
+ { pattern: /baidu/, platform: 'baidu', channel: 'search' },
265
+ { pattern: /yahoo/, platform: 'yahoo', channel: 'search' },
266
+ { pattern: /duckduckgo/, platform: 'duckduckgo', channel: 'search' },
267
+ { pattern: /facebook/, platform: 'facebook', channel: 'social' },
268
+ { pattern: /instagram/, platform: 'instagram', channel: 'social' },
269
+ { pattern: /x\.com|twitter/, platform: 'x', channel: 'social' },
270
+ { pattern: /linkedin/, platform: 'linkedin', channel: 'social' },
271
+ { pattern: /reddit/, platform: 'reddit', channel: 'social' },
272
+ { pattern: /youtube/, platform: 'youtube', channel: 'social' },
273
+ ];
274
+
275
+ const matched = matcherList.find(({ pattern }) => pattern.test(normalized));
276
+ if (!matched) {
277
+ return null;
278
+ }
279
+
280
+ return matched.platform;
281
+ }
282
+
283
+ function detectChannelFromPlatform(platform: string | null | undefined): string | null {
284
+ switch (platform) {
285
+ case 'openai':
286
+ case 'anthropic':
287
+ case 'perplexity':
288
+ case 'gemini':
289
+ case 'copilot':
290
+ return 'ai';
291
+ case 'google':
292
+ case 'bing':
293
+ case 'baidu':
294
+ case 'yahoo':
295
+ case 'duckduckgo':
296
+ return 'search';
297
+ case 'facebook':
298
+ case 'instagram':
299
+ case 'x':
300
+ case 'linkedin':
301
+ case 'reddit':
302
+ case 'youtube':
303
+ return 'social';
304
+ default:
305
+ return null;
306
+ }
307
+ }
308
+
309
+ function parseUserAgent(request: NextRequest): Pick<SourceRefData, 'userAgent' | 'deviceType' | 'os' | 'browser' | 'secChUaMobile' | 'secChUaPlatform'> {
310
+ const userAgentHeader = request.headers.get('user-agent');
311
+ const secChUaMobile = normalizeQueryParam(request.headers.get('sec-ch-ua-mobile')) ?? undefined;
312
+ const secChUaPlatform = normalizeQueryParam(request.headers.get('sec-ch-ua-platform')) ?? undefined;
313
+ const userAgent = normalizeSourceRef(userAgentHeader)?.slice(0, USER_AGENT_MAX_LENGTH) ?? undefined;
314
+ const ua = userAgent?.toLowerCase() ?? '';
315
+
316
+ let deviceType = 'desktop';
317
+ if (!ua) {
318
+ deviceType = 'unknown';
319
+ } else if (/bot|spider|crawler|curl|wget|headless/.test(ua)) {
320
+ deviceType = 'bot';
321
+ } else if (/ipad|tablet/.test(ua)) {
322
+ deviceType = 'tablet';
323
+ } else if (/mobi|iphone|android/.test(ua) || secChUaMobile === '?1') {
324
+ deviceType = 'mobile';
325
+ }
326
+
327
+ let os = 'Unknown';
328
+ if (/iphone|ipad|ipod/.test(ua)) {
329
+ os = 'iOS';
330
+ } else if (/android/.test(ua)) {
331
+ os = 'Android';
332
+ } else if (/windows nt/.test(ua)) {
333
+ os = 'Windows';
334
+ } else if (/mac os x|macintosh/.test(ua)) {
335
+ os = 'macOS';
336
+ } else if (/cros/.test(ua)) {
337
+ os = 'Chrome OS';
338
+ } else if (/linux/.test(ua)) {
339
+ os = 'Linux';
340
+ }
341
+
342
+ if (secChUaPlatform) {
343
+ const normalizedPlatform = secChUaPlatform.replaceAll('"', '');
344
+ if (normalizedPlatform && normalizedPlatform !== 'Unknown') {
345
+ os = normalizedPlatform;
346
+ }
347
+ }
348
+
349
+ let browser = 'Unknown';
350
+ if (/edg\//.test(ua)) {
351
+ browser = 'Edge';
352
+ } else if (/opr\//.test(ua) || /opera/.test(ua)) {
353
+ browser = 'Opera';
354
+ } else if (/samsungbrowser\//.test(ua)) {
355
+ browser = 'Samsung Internet';
356
+ } else if (/crios\//.test(ua) || /chrome\//.test(ua)) {
357
+ browser = 'Chrome';
358
+ } else if (/firefox\//.test(ua)) {
359
+ browser = 'Firefox';
360
+ } else if (/safari\//.test(ua) && !/chrome\//.test(ua) && !/crios\//.test(ua)) {
361
+ browser = 'Safari';
362
+ }
363
+
364
+ return {
365
+ userAgent,
366
+ deviceType,
367
+ os,
368
+ browser,
369
+ secChUaMobile,
370
+ secChUaPlatform,
371
+ };
372
+ }
373
+
374
+ function parseFirstTouchHeader(request: NextRequest): SourceRefData | null {
375
+ const rawHeader = request.headers.get(FIRST_TOUCH_HEADER_NAME);
376
+ const normalizedHeader = normalizeSourceRef(rawHeader)?.slice(0, FIRST_TOUCH_HEADER_MAX_LENGTH);
377
+ if (!normalizedHeader) {
378
+ return null;
379
+ }
380
+
381
+ const decodedHeader = decodeHeaderValue(normalizedHeader);
382
+ if (!decodedHeader) {
383
+ return null;
384
+ }
385
+
386
+ try {
387
+ const parsed = JSON.parse(decodedHeader) as Record<string, unknown>;
388
+ const sourceRef: SourceRefData = {};
389
+
390
+ sourceRef.capturedAt = normalizeQueryParam(typeof parsed.capturedAt === 'string' ? parsed.capturedAt : null) ?? undefined;
391
+ sourceRef.landingUrl = normalizeSourceRef(typeof parsed.landingUrl === 'string' ? parsed.landingUrl : null) ?? undefined;
392
+ sourceRef.landingPath = normalizeSourceRef(typeof parsed.landingPath === 'string' ? parsed.landingPath : null) ?? undefined;
393
+ sourceRef.landingHost = normalizeHost(typeof parsed.landingHost === 'string' ? parsed.landingHost : null) ?? undefined;
394
+ sourceRef.ref = normalizeQueryParam(typeof parsed.ref === 'string' ? parsed.ref : null) ?? undefined;
395
+ sourceRef.utmSource = normalizeQueryParam(typeof parsed.utmSource === 'string' ? parsed.utmSource : null) ?? undefined;
396
+ sourceRef.utmMedium = normalizeQueryParam(typeof parsed.utmMedium === 'string' ? parsed.utmMedium : null) ?? undefined;
397
+ sourceRef.utmCampaign = normalizeQueryParam(typeof parsed.utmCampaign === 'string' ? parsed.utmCampaign : null) ?? undefined;
398
+ sourceRef.utmTerm = normalizeQueryParam(typeof parsed.utmTerm === 'string' ? parsed.utmTerm : null) ?? undefined;
399
+ sourceRef.utmContent = normalizeQueryParam(typeof parsed.utmContent === 'string' ? parsed.utmContent : null) ?? undefined;
400
+ sourceRef.utmId = normalizeQueryParam(typeof parsed.utmId === 'string' ? parsed.utmId : null) ?? undefined;
401
+ sourceRef.gclid = normalizeQueryParam(typeof parsed.gclid === 'string' ? parsed.gclid : null) ?? undefined;
402
+ sourceRef.fbclid = normalizeQueryParam(typeof parsed.fbclid === 'string' ? parsed.fbclid : null) ?? undefined;
403
+ sourceRef.msclkid = normalizeQueryParam(typeof parsed.msclkid === 'string' ? parsed.msclkid : null) ?? undefined;
404
+ sourceRef.ttclid = normalizeQueryParam(typeof parsed.ttclid === 'string' ? parsed.ttclid : null) ?? undefined;
405
+ sourceRef.twclid = normalizeQueryParam(typeof parsed.twclid === 'string' ? parsed.twclid : null) ?? undefined;
406
+ sourceRef.liFatId = normalizeQueryParam(typeof parsed.liFatId === 'string' ? parsed.liFatId : null) ?? undefined;
407
+
408
+ const externalReferrer = normalizeSourceRef(typeof parsed.externalReferrer === 'string' ? parsed.externalReferrer : null);
409
+ if (externalReferrer) {
410
+ sourceRef.httpRefer = externalReferrer;
411
+ try {
412
+ const refererUrl = new URL(externalReferrer);
413
+ sourceRef.refererHost = normalizeHost(refererUrl.host) ?? undefined;
414
+ sourceRef.refererPath = normalizeSourceRef(refererUrl.pathname) ?? undefined;
415
+ sourceRef.refererDomain = getRootDomain(refererUrl.host) ?? undefined;
416
+ applySearchParams(sourceRef, refererUrl.searchParams);
417
+ } catch (error) {
418
+ console.warn('Failed to parse first-touch referrer url:', error);
419
+ }
420
+ }
421
+
422
+ return Object.keys(sourceRef).length > 0 ? sourceRef : null;
423
+ } catch (error) {
424
+ console.warn('Failed to parse first-touch header:', error);
425
+ return null;
426
+ }
427
+ }
428
+
429
+ function finalizeAttribution(sourceRef: SourceRefData) {
430
+ const landingHost = normalizeHost(sourceRef.landingHost);
431
+ const refererHost = normalizeHost(sourceRef.refererHost);
432
+ const internal = isInternalReferer(landingHost, refererHost);
433
+ if (internal) {
434
+ sourceRef.isInternalReferer = true;
435
+ }
436
+
437
+ const utmPlatform = detectPlatform(sourceRef.utmSource) || detectPlatform(sourceRef.ref);
438
+ if (utmPlatform) {
439
+ sourceRef.sourcePlatform = utmPlatform;
440
+ sourceRef.sourceChannel = detectChannelFromPlatform(utmPlatform) ?? sourceRef.sourceChannel ?? 'campaign';
441
+ sourceRef.sourceType = 'campaign';
442
+ return;
443
+ }
444
+
445
+ if (sourceRef.gclid) {
446
+ sourceRef.sourcePlatform = 'google';
447
+ sourceRef.sourceChannel = 'search';
448
+ sourceRef.sourceType = 'campaign';
449
+ return;
450
+ }
451
+
452
+ if (sourceRef.msclkid) {
453
+ sourceRef.sourcePlatform = 'bing';
454
+ sourceRef.sourceChannel = 'search';
455
+ sourceRef.sourceType = 'campaign';
456
+ return;
457
+ }
458
+
459
+ if (sourceRef.fbclid) {
460
+ sourceRef.sourcePlatform = 'facebook';
461
+ sourceRef.sourceChannel = 'social';
462
+ sourceRef.sourceType = 'campaign';
463
+ return;
464
+ }
465
+
466
+ if (sourceRef.ttclid) {
467
+ sourceRef.sourcePlatform = 'tiktok';
468
+ sourceRef.sourceChannel = 'social';
469
+ sourceRef.sourceType = 'campaign';
470
+ return;
471
+ }
472
+
473
+ if (sourceRef.twclid) {
474
+ sourceRef.sourcePlatform = 'x';
475
+ sourceRef.sourceChannel = 'social';
476
+ sourceRef.sourceType = 'campaign';
477
+ return;
478
+ }
479
+
480
+ if (sourceRef.liFatId) {
481
+ sourceRef.sourcePlatform = 'linkedin';
482
+ sourceRef.sourceChannel = 'social';
483
+ sourceRef.sourceType = 'campaign';
484
+ return;
485
+ }
486
+
487
+ if (!internal && refererHost) {
488
+ const refererPlatform = detectPlatform(refererHost) || detectPlatform(sourceRef.httpRefer);
489
+ sourceRef.sourcePlatform = refererPlatform ?? getRootDomain(refererHost) ?? refererHost;
490
+ sourceRef.sourceChannel = detectChannelFromPlatform(refererPlatform) ?? 'referral';
491
+ sourceRef.sourceType = 'referer';
492
+ return;
493
+ }
494
+
495
+ sourceRef.sourcePlatform = 'direct';
496
+ sourceRef.sourceChannel = 'direct';
497
+ sourceRef.sourceType = 'direct';
134
498
  }
135
499
 
136
500
  // 提取用户首次访问来源
@@ -138,21 +502,26 @@ function extractSourceRef(request: NextRequest): SourceRefData | null {
138
502
  const headerRef = request.headers.get('referer') || request.headers.get('referrer');
139
503
  const customRef = request.headers.get('x-source-ref');
140
504
  const queryRef = request.nextUrl.searchParams.get('ref');
141
- console.log({
142
- headerRef,
143
- customRef,
144
- queryRef
145
- })
505
+ const firstTouchRef = parseFirstTouchHeader(request);
506
+
507
+ const sourceRef: SourceRefData = {
508
+ ...parseUserAgent(request),
509
+ };
146
510
 
147
- const sourceRef: SourceRefData = {};
511
+ mergeSourceRef(sourceRef, firstTouchRef);
512
+
513
+ sourceRef.landingUrl = sourceRef.landingUrl ?? normalizeSourceRef(request.nextUrl.toString()) ?? undefined;
514
+ sourceRef.landingPath = sourceRef.landingPath ?? normalizeSourceRef(request.nextUrl.pathname) ?? undefined;
515
+ sourceRef.landingHost = sourceRef.landingHost ?? normalizeHost(request.nextUrl.host) ?? undefined;
516
+ sourceRef.ref = sourceRef.ref ?? normalizeQueryParam(queryRef) ?? undefined;
148
517
 
149
518
  let normalizedHttpRef: string | null = null;
150
- const candidates = [headerRef, customRef, queryRef];
519
+ const candidates = [customRef, headerRef];
151
520
  for (const candidate of candidates) {
152
521
  const normalized = normalizeSourceRef(candidate);
153
522
  if (normalized) {
154
523
  normalizedHttpRef = normalized;
155
- sourceRef.httpRefer = normalized;
524
+ sourceRef.httpRefer = sourceRef.httpRefer ?? normalized;
156
525
  break;
157
526
  }
158
527
  }
@@ -163,12 +532,17 @@ function extractSourceRef(request: NextRequest): SourceRefData | null {
163
532
  if (normalizedHttpRef) {
164
533
  try {
165
534
  const refererUrl = new URL(normalizedHttpRef);
535
+ sourceRef.refererHost = sourceRef.refererHost ?? normalizeHost(refererUrl.host) ?? undefined;
536
+ sourceRef.refererPath = sourceRef.refererPath ?? normalizeSourceRef(refererUrl.pathname) ?? undefined;
537
+ sourceRef.refererDomain = sourceRef.refererDomain ?? getRootDomain(refererUrl.host) ?? undefined;
166
538
  applySearchParams(sourceRef, refererUrl.searchParams);
167
539
  } catch (error) {
168
540
  console.warn('Failed to parse referer url for utm/ref:', error);
169
541
  }
170
542
  }
171
543
 
544
+ finalizeAttribution(sourceRef);
545
+
172
546
  return Object.keys(sourceRef).length > 0 ? sourceRef : null;
173
547
  }
174
548