@windrun-huaiin/backend-core 30.0.0 → 31.0.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.
Files changed (61) hide show
  1. package/README.md +95 -0
  2. package/dist/app/api/user/anonymous/init/fingerprint-only-route.d.ts +8 -0
  3. package/dist/app/api/user/anonymous/init/fingerprint-only-route.d.ts.map +1 -0
  4. package/dist/app/api/user/anonymous/init/fingerprint-only-route.js +20 -0
  5. package/dist/app/api/user/anonymous/init/fingerprint-only-route.mjs +18 -0
  6. package/dist/app/api/user/anonymous/init/route-shared.d.ts +10 -0
  7. package/dist/app/api/user/anonymous/init/route-shared.d.ts.map +1 -0
  8. package/dist/app/api/user/anonymous/init/route-shared.js +557 -0
  9. package/dist/app/api/user/anonymous/init/route-shared.mjs +555 -0
  10. package/dist/app/api/user/anonymous/init/route.d.ts +3 -3
  11. package/dist/app/api/user/anonymous/init/route.d.ts.map +1 -1
  12. package/dist/app/api/user/anonymous/init/route.js +6 -554
  13. package/dist/app/api/user/anonymous/init/route.mjs +7 -555
  14. package/dist/app/api/webhook/clerk/user/route.js +16 -16
  15. package/dist/app/api/webhook/clerk/user/route.mjs +16 -16
  16. package/dist/auth/auth-utils.d.ts +8 -23
  17. package/dist/auth/auth-utils.d.ts.map +1 -1
  18. package/dist/auth/auth-utils.js +8 -20
  19. package/dist/auth/auth-utils.mjs +8 -20
  20. package/dist/lib/money-price-config.d.ts +28 -28
  21. package/dist/lib/money-price-config.js +31 -31
  22. package/dist/lib/money-price-config.mjs +31 -31
  23. package/dist/lib/stripe-config.js +3 -3
  24. package/dist/lib/stripe-config.mjs +3 -3
  25. package/dist/prisma/prisma-transaction-util.js +1 -1
  26. package/dist/prisma/prisma-transaction-util.mjs +1 -1
  27. package/dist/prisma/prisma.d.ts.map +1 -1
  28. package/dist/prisma/prisma.js +18 -19
  29. package/dist/prisma/prisma.mjs +18 -19
  30. package/dist/services/aggregate/billing.aggregate.service.js +6 -6
  31. package/dist/services/aggregate/billing.aggregate.service.mjs +6 -6
  32. package/dist/services/aggregate/user.aggregate.service.d.ts +9 -9
  33. package/dist/services/aggregate/user.aggregate.service.js +16 -16
  34. package/dist/services/aggregate/user.aggregate.service.mjs +16 -16
  35. package/dist/services/database/constants.js +34 -34
  36. package/dist/services/database/constants.mjs +34 -34
  37. package/dist/services/database/credit.service.js +2 -2
  38. package/dist/services/database/credit.service.mjs +2 -2
  39. package/dist/services/database/transaction.service.js +1 -1
  40. package/dist/services/database/transaction.service.mjs +1 -1
  41. package/dist/services/database/user.service.js +2 -2
  42. package/dist/services/database/user.service.mjs +2 -2
  43. package/dist/services/stripe/webhook-handler.js +5 -5
  44. package/dist/services/stripe/webhook-handler.mjs +5 -5
  45. package/package.json +18 -6
  46. package/src/app/api/user/anonymous/init/fingerprint-only-route.ts +14 -0
  47. package/src/app/api/user/anonymous/init/route-shared.ts +710 -0
  48. package/src/app/api/user/anonymous/init/route.ts +7 -712
  49. package/src/app/api/webhook/clerk/user/route.ts +17 -17
  50. package/src/auth/auth-utils.ts +8 -23
  51. package/src/lib/money-price-config.ts +31 -32
  52. package/src/lib/stripe-config.ts +3 -3
  53. package/src/prisma/prisma-transaction-util.ts +1 -1
  54. package/src/prisma/prisma.ts +18 -19
  55. package/src/services/aggregate/billing.aggregate.service.ts +7 -7
  56. package/src/services/aggregate/user.aggregate.service.ts +16 -16
  57. package/src/services/database/constants.ts +34 -34
  58. package/src/services/database/credit.service.ts +2 -2
  59. package/src/services/database/transaction.service.ts +1 -1
  60. package/src/services/database/user.service.ts +2 -2
  61. package/src/services/stripe/webhook-handler.ts +5 -5
@@ -1,719 +1,14 @@
1
- /* eslint-disable @typescript-eslint/no-explicit-any */
2
-
3
- // Fix BigInt serialization issue
4
- (BigInt.prototype as any).toJSON = function () {
5
- return this.toString();
6
- };
7
-
8
- import { anonymousAggregateService } from '@core/aggregate/anonymous.aggregate.service';
9
1
  import { getOptionalServerAuthIdentity } from '@core/auth/auth-utils';
10
- import type { XCredit, XSubscription, XUser } from '@windrun-huaiin/third-ui/fingerprint';
11
- import { extractFingerprintFromNextRequest } from '@windrun-huaiin/third-ui/fingerprint/server';
12
- import { NextRequest, NextResponse } from 'next/server';
13
- import {
14
- fetchLatestUserContextByFingerprintId,
15
- fetchUserContextByClerkUserId,
16
- mapCreditToXCredit,
17
- mapSubscriptionToXSubscription,
18
- mapUserToXUser,
19
- type UserContextEntities,
20
- } from '@core/context/user-context-service';
21
- import { finalizeUserContext } from '@core/context/user-context-finalizer';
22
-
23
- import type { CoreJsonObject } from '@core/db/prisma-model-type';
24
-
25
-
26
- // ==================== 类型定义 ====================
27
-
28
- /** 成功响应数据 */
29
- interface XUserResponse {
30
- success: true;
31
- xUser: XUser;
32
- xCredit: XCredit | null;
33
- xSubscription: XSubscription | null;
34
- isNewUser: boolean;
35
- totalUsersOnDevice?: number;
36
- hasAnonymousUser?: boolean;
37
- }
38
-
39
- /** 错误响应数据 */
40
- interface ErrorResponse {
41
- error: string;
42
- }
43
-
44
- // ==================== 工具函数 ====================
45
-
46
- /** 创建成功响应对象 */
47
- function createSuccessResponse(params: {
48
- entities: UserContextEntities;
49
- isNewUser: boolean;
50
- options?: {
51
- totalUsersOnDevice?: number;
52
- hasAnonymousUser?: boolean;
53
- };
54
- }): XUserResponse {
55
- const response: XUserResponse = {
56
- success: true,
57
- xUser: mapUserToXUser(params.entities.user),
58
- xCredit: params.entities.credit ? mapCreditToXCredit(params.entities.credit) : null,
59
- xSubscription: mapSubscriptionToXSubscription(params.entities.subscription),
60
- isNewUser: params.isNewUser,
61
- ...params.options,
62
- };
63
-
64
- return finalizeUserContext(response);
65
- }
66
-
67
- /** 创建错误响应 */
68
- function createErrorResponse(message: string, status = 400): NextResponse {
69
- const errorResponse: ErrorResponse = { error: message };
70
- return NextResponse.json(errorResponse, { status });
71
- }
72
-
73
- type SourceRefData = CoreJsonObject & {
74
- capturedAt?: string;
75
- landingUrl?: string;
76
- landingPath?: string;
77
- landingHost?: string;
78
- httpRefer?: string;
79
- refererHost?: string;
80
- refererPath?: string;
81
- refererDomain?: string;
82
- sourceType?: string;
83
- sourceChannel?: string;
84
- sourcePlatform?: string;
85
- isInternalReferer?: boolean;
86
- utmSource?: string;
87
- utmMedium?: string;
88
- utmCampaign?: string;
89
- utmTerm?: string;
90
- utmContent?: string;
91
- utmId?: string;
92
- ref?: string;
93
- gclid?: string;
94
- fbclid?: string;
95
- msclkid?: string;
96
- ttclid?: string;
97
- twclid?: string;
98
- liFatId?: string;
99
- userAgent?: string;
100
- deviceType?: string;
101
- os?: string;
102
- browser?: string;
103
- secChUaMobile?: string;
104
- secChUaPlatform?: string;
105
- };
106
-
107
- type SourceRefKey =
108
- | 'utmSource'
109
- | 'utmMedium'
110
- | 'utmCampaign'
111
- | 'utmTerm'
112
- | 'utmContent'
113
- | 'utmId'
114
- | 'ref'
115
- | 'gclid'
116
- | 'fbclid'
117
- | 'msclkid'
118
- | 'ttclid'
119
- | 'twclid'
120
- | 'liFatId';
121
-
122
- const SOURCE_REF_MAX_LENGTH = 2048;
123
- const QUERY_PARAM_MAX_LENGTH = 512;
124
- const USER_AGENT_MAX_LENGTH = 1024;
125
- const FIRST_TOUCH_HEADER_MAX_LENGTH = 4096;
126
- const FIRST_TOUCH_HEADER_NAME = 'x-first-touch';
127
-
128
- function normalizeSourceRef(ref: string | null): string | null {
129
- if (!ref) {
130
- return null;
131
- }
132
-
133
- const trimmed = ref.trim();
134
- if (!trimmed) {
135
- return null;
136
- }
137
-
138
- return trimmed.length > SOURCE_REF_MAX_LENGTH
139
- ? trimmed.slice(0, SOURCE_REF_MAX_LENGTH)
140
- : trimmed;
141
- }
142
-
143
- function normalizeQueryParam(value: string | null): string | null {
144
- if (!value) {
145
- return null;
146
- }
147
-
148
- const trimmed = value.trim();
149
- if (!trimmed) {
150
- return null;
151
- }
152
-
153
- return trimmed.length > QUERY_PARAM_MAX_LENGTH
154
- ? trimmed.slice(0, QUERY_PARAM_MAX_LENGTH)
155
- : trimmed;
156
- }
157
-
158
- function decodeHeaderValue(value: string): string | null {
159
- try {
160
- return decodeURIComponent(value);
161
- } catch {
162
- return null;
163
- }
164
- }
165
-
166
- function mergeSourceRef(target: SourceRefData, source: SourceRefData | null | undefined) {
167
- if (!source) {
168
- return;
169
- }
170
-
171
- const entries = Object.entries(source) as Array<[keyof SourceRefData, SourceRefData[keyof SourceRefData]]>;
172
- for (const [key, value] of entries) {
173
- if (value === undefined || value === null) {
174
- continue;
175
- }
176
-
177
- if (target[key] === undefined) {
178
- (target as Record<string, unknown>)[key as string] = value;
179
- }
180
- }
181
- }
182
-
183
- function applySearchParams(sourceRef: SourceRefData, params: URLSearchParams) {
184
- const setIfEmpty = (key: SourceRefKey, value: string | null) => {
185
- if (sourceRef[key] !== undefined) {
186
- return;
187
- }
188
- const normalized = normalizeQueryParam(value);
189
- if (normalized) {
190
- sourceRef[key] = normalized;
191
- }
192
- };
193
-
194
- setIfEmpty('utmSource', params.get('utm_source'));
195
- setIfEmpty('utmMedium', params.get('utm_medium'));
196
- setIfEmpty('utmCampaign', params.get('utm_campaign'));
197
- setIfEmpty('utmTerm', params.get('utm_term'));
198
- setIfEmpty('utmContent', params.get('utm_content'));
199
- setIfEmpty('utmId', params.get('utm_id'));
200
- setIfEmpty('ref', params.get('ref'));
201
- setIfEmpty('gclid', params.get('gclid'));
202
- setIfEmpty('fbclid', params.get('fbclid'));
203
- setIfEmpty('msclkid', params.get('msclkid'));
204
- setIfEmpty('ttclid', params.get('ttclid'));
205
- setIfEmpty('twclid', params.get('twclid'));
206
- setIfEmpty('liFatId', params.get('li_fat_id'));
207
- }
208
-
209
- function normalizeHost(host: string | null | undefined): string | null {
210
- if (!host) {
211
- return null;
212
- }
213
-
214
- return host.trim().toLowerCase() || null;
215
- }
216
-
217
- function getRootDomain(host: string | null | undefined): string | null {
218
- const normalizedHost = normalizeHost(host);
219
- if (!normalizedHost) {
220
- return null;
221
- }
222
-
223
- const hostname = normalizedHost.split(':')[0];
224
- if (hostname === 'localhost' || /^\d{1,3}(\.\d{1,3}){3}$/.test(hostname)) {
225
- return hostname;
226
- }
227
-
228
- const parts = hostname.split('.').filter(Boolean);
229
- if (parts.length <= 2) {
230
- return hostname;
231
- }
232
-
233
- return parts.slice(-2).join('.');
234
- }
235
-
236
- function isInternalReferer(landingHost: string | null | undefined, refererHost: string | null | undefined): boolean {
237
- const normalizedLandingHost = normalizeHost(landingHost);
238
- const normalizedRefererHost = normalizeHost(refererHost);
239
- if (!normalizedLandingHost || !normalizedRefererHost) {
240
- return false;
241
- }
242
-
243
- if (normalizedLandingHost === normalizedRefererHost) {
244
- return true;
245
- }
246
-
247
- return normalizedLandingHost.endsWith(`.${normalizedRefererHost}`)
248
- || normalizedRefererHost.endsWith(`.${normalizedLandingHost}`);
249
- }
250
-
251
- function detectPlatform(value: string | null | undefined): string | null {
252
- const normalized = value?.trim().toLowerCase();
253
- if (!normalized) {
254
- return null;
255
- }
256
-
257
- const matcherList: Array<{ pattern: RegExp; platform: string; channel: string; }> = [
258
- { pattern: /chatgpt|chat-openai|openai/, platform: 'openai', channel: 'ai' },
259
- { pattern: /claude|anthropic/, platform: 'anthropic', channel: 'ai' },
260
- { pattern: /perplexity/, platform: 'perplexity', channel: 'ai' },
261
- { pattern: /gemini/, platform: 'gemini', channel: 'ai' },
262
- { pattern: /copilot/, platform: 'copilot', channel: 'ai' },
263
- { pattern: /google/, platform: 'google', channel: 'search' },
264
- { pattern: /bing/, platform: 'bing', channel: 'search' },
265
- { pattern: /baidu/, platform: 'baidu', channel: 'search' },
266
- { pattern: /yahoo/, platform: 'yahoo', channel: 'search' },
267
- { pattern: /duckduckgo/, platform: 'duckduckgo', channel: 'search' },
268
- { pattern: /facebook/, platform: 'facebook', channel: 'social' },
269
- { pattern: /instagram/, platform: 'instagram', channel: 'social' },
270
- { pattern: /x\.com|twitter/, platform: 'x', channel: 'social' },
271
- { pattern: /linkedin/, platform: 'linkedin', channel: 'social' },
272
- { pattern: /reddit/, platform: 'reddit', channel: 'social' },
273
- { pattern: /youtube/, platform: 'youtube', channel: 'social' },
274
- ];
275
-
276
- const matched = matcherList.find(({ pattern }) => pattern.test(normalized));
277
- if (!matched) {
278
- return null;
279
- }
280
-
281
- return matched.platform;
282
- }
283
-
284
- function detectChannelFromPlatform(platform: string | null | undefined): string | null {
285
- switch (platform) {
286
- case 'openai':
287
- case 'anthropic':
288
- case 'perplexity':
289
- case 'gemini':
290
- case 'copilot':
291
- return 'ai';
292
- case 'google':
293
- case 'bing':
294
- case 'baidu':
295
- case 'yahoo':
296
- case 'duckduckgo':
297
- return 'search';
298
- case 'facebook':
299
- case 'instagram':
300
- case 'x':
301
- case 'linkedin':
302
- case 'reddit':
303
- case 'youtube':
304
- return 'social';
305
- default:
306
- return null;
307
- }
308
- }
309
-
310
- function detectChannelFromUtmMedium(value: string | null | undefined): string | null {
311
- const normalized = value?.trim().toLowerCase();
312
- if (!normalized) {
313
- return null;
314
- }
315
-
316
- if (/^(cpc|ppc|paid|paid_search|display|banner|affiliate|email|newsletter|push|sms)$/.test(normalized)) {
317
- return 'campaign';
318
- }
319
-
320
- if (/^(social|social_paid|social-organic|social_organic)$/.test(normalized)) {
321
- return 'social';
322
- }
323
-
324
- if (/^(organic|seo|search)$/.test(normalized)) {
325
- return 'search';
326
- }
327
-
328
- if (/^(referral|partner)$/.test(normalized)) {
329
- return 'referral';
330
- }
331
-
332
- if (/^(ai|llm)$/.test(normalized)) {
333
- return 'ai';
334
- }
335
-
336
- return 'campaign';
337
- }
338
-
339
- function parseUserAgent(request: NextRequest): Pick<SourceRefData, 'userAgent' | 'deviceType' | 'os' | 'browser' | 'secChUaMobile' | 'secChUaPlatform'> {
340
- const userAgentHeader = request.headers.get('user-agent');
341
- const secChUaMobile = normalizeQueryParam(request.headers.get('sec-ch-ua-mobile')) ?? undefined;
342
- const secChUaPlatform = normalizeQueryParam(request.headers.get('sec-ch-ua-platform')) ?? undefined;
343
- const userAgent = normalizeSourceRef(userAgentHeader)?.slice(0, USER_AGENT_MAX_LENGTH) ?? undefined;
344
- const ua = userAgent?.toLowerCase() ?? '';
345
-
346
- let deviceType = 'desktop';
347
- if (!ua) {
348
- deviceType = 'unknown';
349
- } else if (/bot|spider|crawler|curl|wget|headless/.test(ua)) {
350
- deviceType = 'bot';
351
- } else if (/ipad|tablet/.test(ua)) {
352
- deviceType = 'tablet';
353
- } else if (/mobi|iphone|android/.test(ua) || secChUaMobile === '?1') {
354
- deviceType = 'mobile';
355
- }
356
-
357
- let os = 'Unknown';
358
- if (/iphone|ipad|ipod/.test(ua)) {
359
- os = 'iOS';
360
- } else if (/android/.test(ua)) {
361
- os = 'Android';
362
- } else if (/windows nt/.test(ua)) {
363
- os = 'Windows';
364
- } else if (/mac os x|macintosh/.test(ua)) {
365
- os = 'macOS';
366
- } else if (/cros/.test(ua)) {
367
- os = 'Chrome OS';
368
- } else if (/linux/.test(ua)) {
369
- os = 'Linux';
370
- }
371
-
372
- if (secChUaPlatform) {
373
- const normalizedPlatform = secChUaPlatform.replaceAll('"', '');
374
- if (normalizedPlatform && normalizedPlatform !== 'Unknown') {
375
- os = normalizedPlatform;
376
- }
377
- }
378
-
379
- let browser = 'Unknown';
380
- if (/edg\//.test(ua)) {
381
- browser = 'Edge';
382
- } else if (/opr\//.test(ua) || /opera/.test(ua)) {
383
- browser = 'Opera';
384
- } else if (/samsungbrowser\//.test(ua)) {
385
- browser = 'Samsung Internet';
386
- } else if (/crios\//.test(ua) || /chrome\//.test(ua)) {
387
- browser = 'Chrome';
388
- } else if (/firefox\//.test(ua)) {
389
- browser = 'Firefox';
390
- } else if (/safari\//.test(ua) && !/chrome\//.test(ua) && !/crios\//.test(ua)) {
391
- browser = 'Safari';
392
- }
393
-
394
- return {
395
- userAgent,
396
- deviceType,
397
- os,
398
- browser,
399
- secChUaMobile,
400
- secChUaPlatform,
401
- };
402
- }
403
-
404
- function parseFirstTouchHeader(request: NextRequest): SourceRefData | null {
405
- const rawHeader = request.headers.get(FIRST_TOUCH_HEADER_NAME);
406
- const normalizedHeader = normalizeSourceRef(rawHeader)?.slice(0, FIRST_TOUCH_HEADER_MAX_LENGTH);
407
- if (!normalizedHeader) {
408
- return null;
409
- }
410
-
411
- const decodedHeader = decodeHeaderValue(normalizedHeader);
412
- if (!decodedHeader) {
413
- return null;
414
- }
415
-
416
- try {
417
- const parsed = JSON.parse(decodedHeader) as Record<string, unknown>;
418
- const sourceRef: SourceRefData = {};
419
-
420
- sourceRef.capturedAt = normalizeQueryParam(typeof parsed.capturedAt === 'string' ? parsed.capturedAt : null) ?? undefined;
421
- sourceRef.landingUrl = normalizeSourceRef(typeof parsed.landingUrl === 'string' ? parsed.landingUrl : null) ?? undefined;
422
- sourceRef.landingPath = normalizeSourceRef(typeof parsed.landingPath === 'string' ? parsed.landingPath : null) ?? undefined;
423
- sourceRef.landingHost = normalizeHost(typeof parsed.landingHost === 'string' ? parsed.landingHost : null) ?? undefined;
424
- sourceRef.ref = normalizeQueryParam(typeof parsed.ref === 'string' ? parsed.ref : null) ?? undefined;
425
- sourceRef.utmSource = normalizeQueryParam(typeof parsed.utmSource === 'string' ? parsed.utmSource : null) ?? undefined;
426
- sourceRef.utmMedium = normalizeQueryParam(typeof parsed.utmMedium === 'string' ? parsed.utmMedium : null) ?? undefined;
427
- sourceRef.utmCampaign = normalizeQueryParam(typeof parsed.utmCampaign === 'string' ? parsed.utmCampaign : null) ?? undefined;
428
- sourceRef.utmTerm = normalizeQueryParam(typeof parsed.utmTerm === 'string' ? parsed.utmTerm : null) ?? undefined;
429
- sourceRef.utmContent = normalizeQueryParam(typeof parsed.utmContent === 'string' ? parsed.utmContent : null) ?? undefined;
430
- sourceRef.utmId = normalizeQueryParam(typeof parsed.utmId === 'string' ? parsed.utmId : null) ?? undefined;
431
- sourceRef.gclid = normalizeQueryParam(typeof parsed.gclid === 'string' ? parsed.gclid : null) ?? undefined;
432
- sourceRef.fbclid = normalizeQueryParam(typeof parsed.fbclid === 'string' ? parsed.fbclid : null) ?? undefined;
433
- sourceRef.msclkid = normalizeQueryParam(typeof parsed.msclkid === 'string' ? parsed.msclkid : null) ?? undefined;
434
- sourceRef.ttclid = normalizeQueryParam(typeof parsed.ttclid === 'string' ? parsed.ttclid : null) ?? undefined;
435
- sourceRef.twclid = normalizeQueryParam(typeof parsed.twclid === 'string' ? parsed.twclid : null) ?? undefined;
436
- sourceRef.liFatId = normalizeQueryParam(typeof parsed.liFatId === 'string' ? parsed.liFatId : null) ?? undefined;
437
-
438
- const externalReferrer = normalizeSourceRef(typeof parsed.externalReferrer === 'string' ? parsed.externalReferrer : null);
439
- if (externalReferrer) {
440
- sourceRef.httpRefer = externalReferrer;
441
- try {
442
- const refererUrl = new URL(externalReferrer);
443
- sourceRef.refererHost = normalizeHost(refererUrl.host) ?? undefined;
444
- sourceRef.refererPath = normalizeSourceRef(refererUrl.pathname) ?? undefined;
445
- sourceRef.refererDomain = getRootDomain(refererUrl.host) ?? undefined;
446
- applySearchParams(sourceRef, refererUrl.searchParams);
447
- } catch (error) {
448
- console.warn('Failed to parse first-touch referrer url:', error);
449
- }
450
- }
451
-
452
- return Object.keys(sourceRef).length > 0 ? sourceRef : null;
453
- } catch (error) {
454
- console.warn('Failed to parse first-touch header:', error);
455
- return null;
456
- }
457
- }
458
-
459
- function finalizeAttribution(sourceRef: SourceRefData) {
460
- const landingHost = normalizeHost(sourceRef.landingHost);
461
- const refererHost = normalizeHost(sourceRef.refererHost);
462
- const internal = isInternalReferer(landingHost, refererHost);
463
- const hasCampaignMarker = Boolean(
464
- sourceRef.utmSource
465
- || sourceRef.utmMedium
466
- || sourceRef.utmCampaign
467
- || sourceRef.utmTerm
468
- || sourceRef.utmContent
469
- || sourceRef.utmId
470
- || sourceRef.ref
471
- || sourceRef.gclid
472
- || sourceRef.fbclid
473
- || sourceRef.msclkid
474
- || sourceRef.ttclid
475
- || sourceRef.twclid
476
- || sourceRef.liFatId
477
- );
478
- if (internal) {
479
- sourceRef.isInternalReferer = true;
480
- }
481
-
482
- const utmPlatform = detectPlatform(sourceRef.utmSource) || detectPlatform(sourceRef.ref);
483
- if (utmPlatform) {
484
- sourceRef.sourcePlatform = utmPlatform;
485
- sourceRef.sourceChannel = detectChannelFromPlatform(utmPlatform)
486
- ?? detectChannelFromUtmMedium(sourceRef.utmMedium)
487
- ?? sourceRef.sourceChannel
488
- ?? 'campaign';
489
- sourceRef.sourceType = 'campaign';
490
- return;
491
- }
492
-
493
- if (sourceRef.gclid) {
494
- sourceRef.sourcePlatform = 'google';
495
- sourceRef.sourceChannel = 'search';
496
- sourceRef.sourceType = 'campaign';
497
- return;
498
- }
499
-
500
- if (sourceRef.msclkid) {
501
- sourceRef.sourcePlatform = 'bing';
502
- sourceRef.sourceChannel = 'search';
503
- sourceRef.sourceType = 'campaign';
504
- return;
505
- }
506
-
507
- if (sourceRef.fbclid) {
508
- sourceRef.sourcePlatform = 'facebook';
509
- sourceRef.sourceChannel = 'social';
510
- sourceRef.sourceType = 'campaign';
511
- return;
512
- }
513
-
514
- if (sourceRef.ttclid) {
515
- sourceRef.sourcePlatform = 'tiktok';
516
- sourceRef.sourceChannel = 'social';
517
- sourceRef.sourceType = 'campaign';
518
- return;
519
- }
520
-
521
- if (sourceRef.twclid) {
522
- sourceRef.sourcePlatform = 'x';
523
- sourceRef.sourceChannel = 'social';
524
- sourceRef.sourceType = 'campaign';
525
- return;
526
- }
527
-
528
- if (sourceRef.liFatId) {
529
- sourceRef.sourcePlatform = 'linkedin';
530
- sourceRef.sourceChannel = 'social';
531
- sourceRef.sourceType = 'campaign';
532
- return;
533
- }
534
-
535
- if (hasCampaignMarker) {
536
- sourceRef.sourcePlatform = 'other';
537
- sourceRef.sourceChannel = detectChannelFromUtmMedium(sourceRef.utmMedium) ?? 'campaign';
538
- sourceRef.sourceType = 'campaign';
539
- return;
540
- }
541
-
542
- if (!internal && refererHost) {
543
- const refererPlatform = detectPlatform(refererHost) || detectPlatform(sourceRef.httpRefer);
544
- sourceRef.sourcePlatform = refererPlatform ?? 'other';
545
- sourceRef.sourceChannel = detectChannelFromPlatform(refererPlatform) ?? 'referral';
546
- sourceRef.sourceType = 'referer';
547
- return;
548
- }
549
-
550
- sourceRef.sourcePlatform = 'direct';
551
- sourceRef.sourceChannel = 'direct';
552
- sourceRef.sourceType = 'direct';
553
- }
554
-
555
- // 提取用户首次访问来源
556
- function extractSourceRef(request: NextRequest): SourceRefData | null {
557
- const headerRef = request.headers.get('referer') || request.headers.get('referrer');
558
- const customRef = request.headers.get('x-source-ref');
559
- const queryRef = request.nextUrl.searchParams.get('ref');
560
- const firstTouchRef = parseFirstTouchHeader(request);
561
-
562
- const sourceRef: SourceRefData = {
563
- ...parseUserAgent(request),
564
- };
565
-
566
- mergeSourceRef(sourceRef, firstTouchRef);
567
-
568
- sourceRef.landingUrl = sourceRef.landingUrl ?? normalizeSourceRef(request.nextUrl.toString()) ?? undefined;
569
- sourceRef.landingPath = sourceRef.landingPath ?? normalizeSourceRef(request.nextUrl.pathname) ?? undefined;
570
- sourceRef.landingHost = sourceRef.landingHost ?? normalizeHost(request.nextUrl.host) ?? undefined;
571
- sourceRef.ref = sourceRef.ref ?? normalizeQueryParam(queryRef) ?? undefined;
572
-
573
- let normalizedHttpRef: string | null = null;
574
- const candidates = [customRef, headerRef];
575
- for (const candidate of candidates) {
576
- const normalized = normalizeSourceRef(candidate);
577
- if (normalized) {
578
- normalizedHttpRef = normalized;
579
- sourceRef.httpRefer = sourceRef.httpRefer ?? normalized;
580
- break;
581
- }
582
- }
583
-
584
- const searchParams = request.nextUrl.searchParams;
585
- applySearchParams(sourceRef, searchParams);
586
-
587
- if (normalizedHttpRef) {
588
- try {
589
- const refererUrl = new URL(normalizedHttpRef);
590
- sourceRef.refererHost = sourceRef.refererHost ?? normalizeHost(refererUrl.host) ?? undefined;
591
- sourceRef.refererPath = sourceRef.refererPath ?? normalizeSourceRef(refererUrl.pathname) ?? undefined;
592
- sourceRef.refererDomain = sourceRef.refererDomain ?? getRootDomain(refererUrl.host) ?? undefined;
593
- applySearchParams(sourceRef, refererUrl.searchParams);
594
- } catch (error) {
595
- console.warn('Failed to parse referer url for utm/ref:', error);
596
- }
597
- }
598
-
599
- finalizeAttribution(sourceRef);
600
-
601
- return Object.keys(sourceRef).length > 0 ? sourceRef : null;
602
- }
603
-
604
-
605
- /**
606
- * 根据fingerprint_id查询用户并返回响应数据
607
- */
608
- async function getUserByClerkId(clerkUserId: string): Promise<XUserResponse | null> {
609
- const entities = await fetchUserContextByClerkUserId(clerkUserId);
610
- if (!entities) {
611
- return null;
612
- }
613
-
614
- return createSuccessResponse({
615
- entities,
616
- isNewUser: false,
617
- });
618
- }
2
+ import { handleFingerprintRequest } from './route-shared';
3
+ import type { NextRequest } from 'next/server';
619
4
 
620
5
  /**
621
- * 根据fingerprint_id查询用户并返回响应数据
622
- */
623
- async function getUserByFingerprintId(fingerprintId: string): Promise<XUserResponse | null> {
624
- const result = await fetchLatestUserContextByFingerprintId(fingerprintId);
625
- if (!result) {
626
- return null;
627
- }
628
-
629
- const { totalUsersOnDevice, hasAnonymousUser, ...entities } = result;
630
-
631
- return createSuccessResponse({
632
- entities,
633
- isNewUser: false,
634
- options: {
635
- totalUsersOnDevice,
636
- hasAnonymousUser,
637
- },
638
- });
639
- }
640
-
641
- /**
642
- * 通用的fingerprint处理逻辑
643
- */
644
- async function handleFingerprintRequest(request: NextRequest, options: { createIfNotExists?: boolean; } = {}) {
645
- // 从请求中提取fingerprint ID
646
- const fingerprintId = extractFingerprintFromNextRequest(request);
647
- // 验证fingerprint ID
648
- if (!fingerprintId) {
649
- return createErrorResponse('Invalid or missing fingerprint ID');
650
- }
651
- console.log('Received fingerprintId:', fingerprintId);
652
-
653
- const authIdentity = await getOptionalServerAuthIdentity();
654
- const clerkUserId = authIdentity?.providerUserId ?? null;
655
- try {
656
- // 优先根据 Clerk ID 查询(如果已登录)
657
- let existingUserResult: XUserResponse | null = null;
658
- if (clerkUserId) {
659
- // 已登录一律按照clerkUserId去查
660
- existingUserResult = await getUserByClerkId(clerkUserId);
661
- if (existingUserResult && existingUserResult.xUser.fingerprintId !== fingerprintId) {
662
- // 说明当前用户的指纹ID发生了改变,为什么呢?因为它使用同一账号去注册Clerk,Clerk判定是同一用户!
663
- // 这个时候一定以登录用户clerkUserId为准
664
- // 但是考虑到同一指纹ID本身可以绑定多个账号,所以这里什么都不做
665
- // 就是以当前登录用户去查他自己的数据就行!
666
- console.warn(`Current login user used diff fp_ids: ${clerkUserId}, db_fp_id=${existingUserResult.xUser.fingerprintId}, req_fp_id=${fingerprintId}`);
667
- }
668
- } else {
669
- // 其次才是检查是否已存在该fingerprint的用户
670
- existingUserResult = await getUserByFingerprintId(fingerprintId);
671
- }
672
- if (existingUserResult) {
673
- return NextResponse.json(existingUserResult);
674
- }
675
-
676
- // 如果不存在用户且不允许创建,返回404
677
- if (!options.createIfNotExists) {
678
- return createErrorResponse('User not found', 404);
679
- }
680
-
681
- const sourceRef = extractSourceRef(request);
682
-
683
- const anonymousInitResult = await anonymousAggregateService.getOrCreateByFingerprintId(
684
- fingerprintId,
685
- { sourceRef: sourceRef?? undefined}
686
- );
687
-
688
- if (anonymousInitResult.isNewUser) {
689
- console.log(`Created new anonymous user ${anonymousInitResult.user.userId} with fingerprint ${fingerprintId}`);
690
- }
691
-
692
- // 返回创建结果
693
- const response = createSuccessResponse({
694
- entities: {
695
- user: anonymousInitResult.user,
696
- credit: anonymousInitResult.credit,
697
- subscription: anonymousInitResult.subscription,
698
- },
699
- isNewUser: anonymousInitResult.isNewUser,
700
- options: {
701
- totalUsersOnDevice: anonymousInitResult.totalUsersOnDevice,
702
- hasAnonymousUser: anonymousInitResult.hasAnonymousUser,
703
- },
704
- });
705
- return NextResponse.json(response);
706
-
707
- } catch (error) {
708
- console.error('Fingerprint request error:', error);
709
- return createErrorResponse('Internal server error', 500);
710
- }
711
- }
712
-
713
- /**
714
- * 匿名用户初始化API
6
+ * Clerk-aware anonymous user initialization API.
715
7
  * POST /api/user/anonymous/init
716
8
  */
717
9
  export async function POST(request: NextRequest) {
718
- return handleFingerprintRequest(request, { createIfNotExists: true });
10
+ return handleFingerprintRequest(request, {
11
+ createIfNotExists: true,
12
+ getAuthIdentity: getOptionalServerAuthIdentity,
13
+ });
719
14
  }