@windrun-huaiin/backend-core 14.1.0 → 14.2.0

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,85 @@
1
+ import { __awaiter } from '../../node_modules/.pnpm/@rollup_plugin-typescript@12.1.4_rollup@4.46.2_tslib@2.8.1_typescript@5.9.3/node_modules/tslib/tslib.es6.mjs';
2
+ import { userService } from '../database/user.service.mjs';
3
+ import { subscriptionService } from '../database/subscription.service.mjs';
4
+ import { creditService } from '../database/credit.service.mjs';
5
+ import { Prisma } from '@prisma/client';
6
+ import { UserStatus, OperationType, CreditType } from '../database/constants.mjs';
7
+ import '../../prisma/prisma.mjs';
8
+ import { runInTransaction } from '../../prisma/prisma-transaction-util.mjs';
9
+ import { freeAmount } from '../../lib/credit-init.mjs';
10
+
11
+ const ANONYMOUS_INIT_LOCK_NAMESPACE = 92831;
12
+ class AnonymousAggregateService {
13
+ lockFingerprintInit(tx, fingerprintId) {
14
+ return __awaiter(this, void 0, void 0, function* () {
15
+ yield tx.$executeRaw `
16
+ SELECT pg_advisory_xact_lock(
17
+ ${Prisma.raw(String(ANONYMOUS_INIT_LOCK_NAMESPACE))},
18
+ hashtext(${fingerprintId})
19
+ )
20
+ `;
21
+ });
22
+ }
23
+ findLatestUserContextByFingerprintId(fingerprintId, tx) {
24
+ return __awaiter(this, void 0, void 0, function* () {
25
+ const existingUsers = yield userService.findListByFingerprintId(fingerprintId, tx);
26
+ if (existingUsers.length === 0) {
27
+ return null;
28
+ }
29
+ const latestUser = existingUsers[0];
30
+ const [credit, subscription] = yield Promise.all([
31
+ creditService.getCredit(latestUser.userId, tx),
32
+ subscriptionService.getActiveSubscription(latestUser.userId, tx),
33
+ ]);
34
+ return {
35
+ user: latestUser,
36
+ credit,
37
+ subscription,
38
+ isNewUser: false,
39
+ totalUsersOnDevice: existingUsers.length,
40
+ hasAnonymousUser: true,
41
+ };
42
+ });
43
+ }
44
+ createAnonymousUser(fingerprintId, tx, options) {
45
+ return __awaiter(this, void 0, void 0, function* () {
46
+ const newUser = yield userService.createUser({
47
+ fingerprintId,
48
+ sourceRef: options === null || options === void 0 ? void 0 : options.sourceRef,
49
+ status: UserStatus.ANONYMOUS,
50
+ }, tx);
51
+ const credit = yield creditService.initializeCreditWithFree({
52
+ userId: newUser.userId,
53
+ feature: 'anonymous_user_init',
54
+ creditType: CreditType.FREE,
55
+ operationType: OperationType.SYS_GIFT,
56
+ operationReferId: newUser.userId,
57
+ creditsChange: freeAmount,
58
+ }, tx);
59
+ yield subscriptionService.initializeSubscription(newUser.userId, tx);
60
+ return {
61
+ user: newUser,
62
+ credit,
63
+ subscription: null,
64
+ isNewUser: true,
65
+ totalUsersOnDevice: 1,
66
+ hasAnonymousUser: true,
67
+ };
68
+ });
69
+ }
70
+ getOrCreateByFingerprintId(fingerprintId, options) {
71
+ return __awaiter(this, void 0, void 0, function* () {
72
+ return runInTransaction((tx) => __awaiter(this, void 0, void 0, function* () {
73
+ yield this.lockFingerprintInit(tx, fingerprintId);
74
+ const existingContext = yield this.findLatestUserContextByFingerprintId(fingerprintId, tx);
75
+ if (existingContext) {
76
+ return existingContext;
77
+ }
78
+ return this.createAnonymousUser(fingerprintId, tx, options);
79
+ }), 'anonymous_get_or_create_by_fingerprint_id');
80
+ });
81
+ }
82
+ }
83
+ const anonymousAggregateService = new AnonymousAggregateService();
84
+
85
+ export { anonymousAggregateService };
@@ -1,3 +1,4 @@
1
1
  export { userAggregateService } from './user.aggregate.service';
2
2
  export { billingAggregateService } from './billing.aggregate.service';
3
+ export { anonymousAggregateService } from './anonymous.aggregate.service';
3
4
  //# sourceMappingURL=index.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../../src/services/aggregate/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,oBAAoB,EAAE,MAAM,0BAA0B,CAAC;AAChE,OAAO,EAAE,uBAAuB,EAAE,MAAM,6BAA6B,CAAC"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../../src/services/aggregate/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,oBAAoB,EAAE,MAAM,0BAA0B,CAAC;AAChE,OAAO,EAAE,uBAAuB,EAAE,MAAM,6BAA6B,CAAC;AACtE,OAAO,EAAE,yBAAyB,EAAE,MAAM,+BAA+B,CAAC"}
@@ -2,8 +2,10 @@
2
2
 
3
3
  var user_aggregate_service = require('./user.aggregate.service.js');
4
4
  var billing_aggregate_service = require('./billing.aggregate.service.js');
5
+ var anonymous_aggregate_service = require('./anonymous.aggregate.service.js');
5
6
 
6
7
 
7
8
 
8
9
  exports.userAggregateService = user_aggregate_service.userAggregateService;
9
10
  exports.billingAggregateService = billing_aggregate_service.billingAggregateService;
11
+ exports.anonymousAggregateService = anonymous_aggregate_service.anonymousAggregateService;
@@ -1,2 +1,3 @@
1
1
  export { userAggregateService } from './user.aggregate.service.mjs';
2
2
  export { billingAggregateService } from './billing.aggregate.service.mjs';
3
+ export { anonymousAggregateService } from './anonymous.aggregate.service.mjs';
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.2.0",
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.1.0"
99
99
  },
100
100
  "devDependencies": {
101
101
  "@rollup/plugin-alias": "^5.1.1",
@@ -5,8 +5,8 @@
5
5
  return this.toString();
6
6
  };
7
7
 
8
- import { userAggregateService } from '@/aggregate/user.aggregate.service';
9
- import { XCredit, XSubscription, XUser } from '@windrun-huaiin/third-ui/fingerprint';
8
+ import { anonymousAggregateService } from '@/aggregate/anonymous.aggregate.service';
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,360 @@ 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 detectChannelFromUtmMedium(value: string | null | undefined): string | null {
310
+ const normalized = value?.trim().toLowerCase();
311
+ if (!normalized) {
312
+ return null;
313
+ }
314
+
315
+ if (/^(cpc|ppc|paid|paid_search|display|banner|affiliate|email|newsletter|push|sms)$/.test(normalized)) {
316
+ return 'campaign';
317
+ }
318
+
319
+ if (/^(social|social_paid|social-organic|social_organic)$/.test(normalized)) {
320
+ return 'social';
321
+ }
322
+
323
+ if (/^(organic|seo|search)$/.test(normalized)) {
324
+ return 'search';
325
+ }
326
+
327
+ if (/^(referral|partner)$/.test(normalized)) {
328
+ return 'referral';
329
+ }
330
+
331
+ if (/^(ai|llm)$/.test(normalized)) {
332
+ return 'ai';
333
+ }
334
+
335
+ return 'campaign';
336
+ }
337
+
338
+ function parseUserAgent(request: NextRequest): Pick<SourceRefData, 'userAgent' | 'deviceType' | 'os' | 'browser' | 'secChUaMobile' | 'secChUaPlatform'> {
339
+ const userAgentHeader = request.headers.get('user-agent');
340
+ const secChUaMobile = normalizeQueryParam(request.headers.get('sec-ch-ua-mobile')) ?? undefined;
341
+ const secChUaPlatform = normalizeQueryParam(request.headers.get('sec-ch-ua-platform')) ?? undefined;
342
+ const userAgent = normalizeSourceRef(userAgentHeader)?.slice(0, USER_AGENT_MAX_LENGTH) ?? undefined;
343
+ const ua = userAgent?.toLowerCase() ?? '';
344
+
345
+ let deviceType = 'desktop';
346
+ if (!ua) {
347
+ deviceType = 'unknown';
348
+ } else if (/bot|spider|crawler|curl|wget|headless/.test(ua)) {
349
+ deviceType = 'bot';
350
+ } else if (/ipad|tablet/.test(ua)) {
351
+ deviceType = 'tablet';
352
+ } else if (/mobi|iphone|android/.test(ua) || secChUaMobile === '?1') {
353
+ deviceType = 'mobile';
354
+ }
355
+
356
+ let os = 'Unknown';
357
+ if (/iphone|ipad|ipod/.test(ua)) {
358
+ os = 'iOS';
359
+ } else if (/android/.test(ua)) {
360
+ os = 'Android';
361
+ } else if (/windows nt/.test(ua)) {
362
+ os = 'Windows';
363
+ } else if (/mac os x|macintosh/.test(ua)) {
364
+ os = 'macOS';
365
+ } else if (/cros/.test(ua)) {
366
+ os = 'Chrome OS';
367
+ } else if (/linux/.test(ua)) {
368
+ os = 'Linux';
369
+ }
370
+
371
+ if (secChUaPlatform) {
372
+ const normalizedPlatform = secChUaPlatform.replaceAll('"', '');
373
+ if (normalizedPlatform && normalizedPlatform !== 'Unknown') {
374
+ os = normalizedPlatform;
375
+ }
376
+ }
377
+
378
+ let browser = 'Unknown';
379
+ if (/edg\//.test(ua)) {
380
+ browser = 'Edge';
381
+ } else if (/opr\//.test(ua) || /opera/.test(ua)) {
382
+ browser = 'Opera';
383
+ } else if (/samsungbrowser\//.test(ua)) {
384
+ browser = 'Samsung Internet';
385
+ } else if (/crios\//.test(ua) || /chrome\//.test(ua)) {
386
+ browser = 'Chrome';
387
+ } else if (/firefox\//.test(ua)) {
388
+ browser = 'Firefox';
389
+ } else if (/safari\//.test(ua) && !/chrome\//.test(ua) && !/crios\//.test(ua)) {
390
+ browser = 'Safari';
391
+ }
392
+
393
+ return {
394
+ userAgent,
395
+ deviceType,
396
+ os,
397
+ browser,
398
+ secChUaMobile,
399
+ secChUaPlatform,
400
+ };
401
+ }
402
+
403
+ function parseFirstTouchHeader(request: NextRequest): SourceRefData | null {
404
+ const rawHeader = request.headers.get(FIRST_TOUCH_HEADER_NAME);
405
+ const normalizedHeader = normalizeSourceRef(rawHeader)?.slice(0, FIRST_TOUCH_HEADER_MAX_LENGTH);
406
+ if (!normalizedHeader) {
407
+ return null;
408
+ }
409
+
410
+ const decodedHeader = decodeHeaderValue(normalizedHeader);
411
+ if (!decodedHeader) {
412
+ return null;
413
+ }
414
+
415
+ try {
416
+ const parsed = JSON.parse(decodedHeader) as Record<string, unknown>;
417
+ const sourceRef: SourceRefData = {};
418
+
419
+ sourceRef.capturedAt = normalizeQueryParam(typeof parsed.capturedAt === 'string' ? parsed.capturedAt : null) ?? undefined;
420
+ sourceRef.landingUrl = normalizeSourceRef(typeof parsed.landingUrl === 'string' ? parsed.landingUrl : null) ?? undefined;
421
+ sourceRef.landingPath = normalizeSourceRef(typeof parsed.landingPath === 'string' ? parsed.landingPath : null) ?? undefined;
422
+ sourceRef.landingHost = normalizeHost(typeof parsed.landingHost === 'string' ? parsed.landingHost : null) ?? undefined;
423
+ sourceRef.ref = normalizeQueryParam(typeof parsed.ref === 'string' ? parsed.ref : null) ?? undefined;
424
+ sourceRef.utmSource = normalizeQueryParam(typeof parsed.utmSource === 'string' ? parsed.utmSource : null) ?? undefined;
425
+ sourceRef.utmMedium = normalizeQueryParam(typeof parsed.utmMedium === 'string' ? parsed.utmMedium : null) ?? undefined;
426
+ sourceRef.utmCampaign = normalizeQueryParam(typeof parsed.utmCampaign === 'string' ? parsed.utmCampaign : null) ?? undefined;
427
+ sourceRef.utmTerm = normalizeQueryParam(typeof parsed.utmTerm === 'string' ? parsed.utmTerm : null) ?? undefined;
428
+ sourceRef.utmContent = normalizeQueryParam(typeof parsed.utmContent === 'string' ? parsed.utmContent : null) ?? undefined;
429
+ sourceRef.utmId = normalizeQueryParam(typeof parsed.utmId === 'string' ? parsed.utmId : null) ?? undefined;
430
+ sourceRef.gclid = normalizeQueryParam(typeof parsed.gclid === 'string' ? parsed.gclid : null) ?? undefined;
431
+ sourceRef.fbclid = normalizeQueryParam(typeof parsed.fbclid === 'string' ? parsed.fbclid : null) ?? undefined;
432
+ sourceRef.msclkid = normalizeQueryParam(typeof parsed.msclkid === 'string' ? parsed.msclkid : null) ?? undefined;
433
+ sourceRef.ttclid = normalizeQueryParam(typeof parsed.ttclid === 'string' ? parsed.ttclid : null) ?? undefined;
434
+ sourceRef.twclid = normalizeQueryParam(typeof parsed.twclid === 'string' ? parsed.twclid : null) ?? undefined;
435
+ sourceRef.liFatId = normalizeQueryParam(typeof parsed.liFatId === 'string' ? parsed.liFatId : null) ?? undefined;
436
+
437
+ const externalReferrer = normalizeSourceRef(typeof parsed.externalReferrer === 'string' ? parsed.externalReferrer : null);
438
+ if (externalReferrer) {
439
+ sourceRef.httpRefer = externalReferrer;
440
+ try {
441
+ const refererUrl = new URL(externalReferrer);
442
+ sourceRef.refererHost = normalizeHost(refererUrl.host) ?? undefined;
443
+ sourceRef.refererPath = normalizeSourceRef(refererUrl.pathname) ?? undefined;
444
+ sourceRef.refererDomain = getRootDomain(refererUrl.host) ?? undefined;
445
+ applySearchParams(sourceRef, refererUrl.searchParams);
446
+ } catch (error) {
447
+ console.warn('Failed to parse first-touch referrer url:', error);
448
+ }
449
+ }
450
+
451
+ return Object.keys(sourceRef).length > 0 ? sourceRef : null;
452
+ } catch (error) {
453
+ console.warn('Failed to parse first-touch header:', error);
454
+ return null;
455
+ }
456
+ }
457
+
458
+ function finalizeAttribution(sourceRef: SourceRefData) {
459
+ const landingHost = normalizeHost(sourceRef.landingHost);
460
+ const refererHost = normalizeHost(sourceRef.refererHost);
461
+ const internal = isInternalReferer(landingHost, refererHost);
462
+ const hasCampaignMarker = Boolean(
463
+ sourceRef.utmSource
464
+ || sourceRef.utmMedium
465
+ || sourceRef.utmCampaign
466
+ || sourceRef.utmTerm
467
+ || sourceRef.utmContent
468
+ || sourceRef.utmId
469
+ || sourceRef.ref
470
+ || sourceRef.gclid
471
+ || sourceRef.fbclid
472
+ || sourceRef.msclkid
473
+ || sourceRef.ttclid
474
+ || sourceRef.twclid
475
+ || sourceRef.liFatId
476
+ );
477
+ if (internal) {
478
+ sourceRef.isInternalReferer = true;
479
+ }
480
+
481
+ const utmPlatform = detectPlatform(sourceRef.utmSource) || detectPlatform(sourceRef.ref);
482
+ if (utmPlatform) {
483
+ sourceRef.sourcePlatform = utmPlatform;
484
+ sourceRef.sourceChannel = detectChannelFromPlatform(utmPlatform)
485
+ ?? detectChannelFromUtmMedium(sourceRef.utmMedium)
486
+ ?? sourceRef.sourceChannel
487
+ ?? 'campaign';
488
+ sourceRef.sourceType = 'campaign';
489
+ return;
490
+ }
491
+
492
+ if (sourceRef.gclid) {
493
+ sourceRef.sourcePlatform = 'google';
494
+ sourceRef.sourceChannel = 'search';
495
+ sourceRef.sourceType = 'campaign';
496
+ return;
497
+ }
498
+
499
+ if (sourceRef.msclkid) {
500
+ sourceRef.sourcePlatform = 'bing';
501
+ sourceRef.sourceChannel = 'search';
502
+ sourceRef.sourceType = 'campaign';
503
+ return;
504
+ }
505
+
506
+ if (sourceRef.fbclid) {
507
+ sourceRef.sourcePlatform = 'facebook';
508
+ sourceRef.sourceChannel = 'social';
509
+ sourceRef.sourceType = 'campaign';
510
+ return;
511
+ }
512
+
513
+ if (sourceRef.ttclid) {
514
+ sourceRef.sourcePlatform = 'tiktok';
515
+ sourceRef.sourceChannel = 'social';
516
+ sourceRef.sourceType = 'campaign';
517
+ return;
518
+ }
519
+
520
+ if (sourceRef.twclid) {
521
+ sourceRef.sourcePlatform = 'x';
522
+ sourceRef.sourceChannel = 'social';
523
+ sourceRef.sourceType = 'campaign';
524
+ return;
525
+ }
526
+
527
+ if (sourceRef.liFatId) {
528
+ sourceRef.sourcePlatform = 'linkedin';
529
+ sourceRef.sourceChannel = 'social';
530
+ sourceRef.sourceType = 'campaign';
531
+ return;
532
+ }
533
+
534
+ if (hasCampaignMarker) {
535
+ sourceRef.sourcePlatform = 'other';
536
+ sourceRef.sourceChannel = detectChannelFromUtmMedium(sourceRef.utmMedium) ?? 'campaign';
537
+ sourceRef.sourceType = 'campaign';
538
+ return;
539
+ }
540
+
541
+ if (!internal && refererHost) {
542
+ const refererPlatform = detectPlatform(refererHost) || detectPlatform(sourceRef.httpRefer);
543
+ sourceRef.sourcePlatform = refererPlatform ?? 'other';
544
+ sourceRef.sourceChannel = detectChannelFromPlatform(refererPlatform) ?? 'referral';
545
+ sourceRef.sourceType = 'referer';
546
+ return;
547
+ }
548
+
549
+ sourceRef.sourcePlatform = 'direct';
550
+ sourceRef.sourceChannel = 'direct';
551
+ sourceRef.sourceType = 'direct';
134
552
  }
135
553
 
136
554
  // 提取用户首次访问来源
@@ -138,21 +556,26 @@ function extractSourceRef(request: NextRequest): SourceRefData | null {
138
556
  const headerRef = request.headers.get('referer') || request.headers.get('referrer');
139
557
  const customRef = request.headers.get('x-source-ref');
140
558
  const queryRef = request.nextUrl.searchParams.get('ref');
141
- console.log({
142
- headerRef,
143
- customRef,
144
- queryRef
145
- })
559
+ const firstTouchRef = parseFirstTouchHeader(request);
560
+
561
+ const sourceRef: SourceRefData = {
562
+ ...parseUserAgent(request),
563
+ };
564
+
565
+ mergeSourceRef(sourceRef, firstTouchRef);
146
566
 
147
- const sourceRef: SourceRefData = {};
567
+ sourceRef.landingUrl = sourceRef.landingUrl ?? normalizeSourceRef(request.nextUrl.toString()) ?? undefined;
568
+ sourceRef.landingPath = sourceRef.landingPath ?? normalizeSourceRef(request.nextUrl.pathname) ?? undefined;
569
+ sourceRef.landingHost = sourceRef.landingHost ?? normalizeHost(request.nextUrl.host) ?? undefined;
570
+ sourceRef.ref = sourceRef.ref ?? normalizeQueryParam(queryRef) ?? undefined;
148
571
 
149
572
  let normalizedHttpRef: string | null = null;
150
- const candidates = [headerRef, customRef, queryRef];
573
+ const candidates = [customRef, headerRef];
151
574
  for (const candidate of candidates) {
152
575
  const normalized = normalizeSourceRef(candidate);
153
576
  if (normalized) {
154
577
  normalizedHttpRef = normalized;
155
- sourceRef.httpRefer = normalized;
578
+ sourceRef.httpRefer = sourceRef.httpRefer ?? normalized;
156
579
  break;
157
580
  }
158
581
  }
@@ -163,12 +586,17 @@ function extractSourceRef(request: NextRequest): SourceRefData | null {
163
586
  if (normalizedHttpRef) {
164
587
  try {
165
588
  const refererUrl = new URL(normalizedHttpRef);
589
+ sourceRef.refererHost = sourceRef.refererHost ?? normalizeHost(refererUrl.host) ?? undefined;
590
+ sourceRef.refererPath = sourceRef.refererPath ?? normalizeSourceRef(refererUrl.pathname) ?? undefined;
591
+ sourceRef.refererDomain = sourceRef.refererDomain ?? getRootDomain(refererUrl.host) ?? undefined;
166
592
  applySearchParams(sourceRef, refererUrl.searchParams);
167
593
  } catch (error) {
168
594
  console.warn('Failed to parse referer url for utm/ref:', error);
169
595
  }
170
596
  }
171
597
 
598
+ finalizeAttribution(sourceRef);
599
+
172
600
  return Object.keys(sourceRef).length > 0 ? sourceRef : null;
173
601
  }
174
602
 
@@ -250,22 +678,27 @@ async function handleFingerprintRequest(request: NextRequest, options: { createI
250
678
 
251
679
  const sourceRef = extractSourceRef(request);
252
680
 
253
- // 创建新的匿名用户
254
- const { newUser, credit } = await userAggregateService.initAnonymousUser(
681
+ const anonymousInitResult = await anonymousAggregateService.getOrCreateByFingerprintId(
255
682
  fingerprintId,
256
683
  { sourceRef: sourceRef?? undefined}
257
684
  );
258
685
 
259
- console.log(`Created new anonymous user ${newUser.userId} with fingerprint ${fingerprintId}`);
686
+ if (anonymousInitResult.isNewUser) {
687
+ console.log(`Created new anonymous user ${anonymousInitResult.user.userId} with fingerprint ${fingerprintId}`);
688
+ }
260
689
 
261
690
  // 返回创建结果
262
691
  const response = createSuccessResponse({
263
692
  entities: {
264
- user: newUser,
265
- credit,
266
- subscription: null,
693
+ user: anonymousInitResult.user,
694
+ credit: anonymousInitResult.credit,
695
+ subscription: anonymousInitResult.subscription,
696
+ },
697
+ isNewUser: anonymousInitResult.isNewUser,
698
+ options: {
699
+ totalUsersOnDevice: anonymousInitResult.totalUsersOnDevice,
700
+ hasAnonymousUser: anonymousInitResult.hasAnonymousUser,
267
701
  },
268
- isNewUser: true,
269
702
  });
270
703
  return NextResponse.json(response);
271
704