@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.
- package/dist/app/api/user/anonymous/init/route.d.ts.map +1 -1
- package/dist/app/api/user/anonymous/init/route.js +374 -16
- package/dist/app/api/user/anonymous/init/route.mjs +374 -16
- package/dist/index.js +2 -0
- package/dist/index.mjs +1 -0
- package/dist/services/aggregate/anonymous.aggregate.service.d.ts +21 -0
- package/dist/services/aggregate/anonymous.aggregate.service.d.ts.map +1 -0
- package/dist/services/aggregate/anonymous.aggregate.service.js +87 -0
- package/dist/services/aggregate/anonymous.aggregate.service.mjs +85 -0
- package/dist/services/aggregate/index.d.ts +1 -0
- package/dist/services/aggregate/index.d.ts.map +1 -1
- package/dist/services/aggregate/index.js +2 -0
- package/dist/services/aggregate/index.mjs +1 -0
- package/package.json +2 -2
- package/src/app/api/user/anonymous/init/route.ts +451 -18
- package/src/services/aggregate/anonymous.aggregate.service.ts +113 -0
- package/src/services/aggregate/index.ts +1 -0
|
@@ -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 +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;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@windrun-huaiin/backend-core",
|
|
3
|
-
"version": "14.
|
|
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.
|
|
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 {
|
|
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 =
|
|
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
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
}
|
|
559
|
+
const firstTouchRef = parseFirstTouchHeader(request);
|
|
560
|
+
|
|
561
|
+
const sourceRef: SourceRefData = {
|
|
562
|
+
...parseUserAgent(request),
|
|
563
|
+
};
|
|
564
|
+
|
|
565
|
+
mergeSourceRef(sourceRef, firstTouchRef);
|
|
146
566
|
|
|
147
|
-
|
|
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 = [
|
|
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
|
-
|
|
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:
|
|
265
|
-
credit,
|
|
266
|
-
subscription:
|
|
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
|
|