@windrun-huaiin/backend-core 14.0.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,4 +1,4 @@
1
1
  import { PrismaClient, Prisma } from '@prisma/client';
2
- export declare const prisma: any;
2
+ export declare const prisma: PrismaClient<Prisma.PrismaClientOptions, never, import("@prisma/client/runtime/library").DefaultArgs>;
3
3
  export declare function checkAndFallbackWithNonTCClient(tx?: Prisma.TransactionClient): Prisma.TransactionClient | PrismaClient;
4
4
  //# sourceMappingURL=prisma.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"prisma.d.ts","sourceRoot":"","sources":["../../src/prisma/prisma.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,YAAY,EAAE,MAAM,EAAE,MAAM,gBAAgB,CAAC;AAgCtD,eAAO,MAAM,MAAM,KAIf,CAAC;AAmFL,wBAAgB,+BAA+B,CAAC,EAAE,CAAC,EAAE,MAAM,CAAC,iBAAiB,GAAG,MAAM,CAAC,iBAAiB,GAAG,YAAY,CAEtH"}
1
+ {"version":3,"file":"prisma.d.ts","sourceRoot":"","sources":["../../src/prisma/prisma.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,YAAY,EAAE,MAAM,EAAE,MAAM,gBAAgB,CAAC;AAgCtD,eAAO,MAAM,MAAM,uGAIf,CAAC;AAmFL,wBAAgB,+BAA+B,CAAC,EAAE,CAAC,EAAE,MAAM,CAAC,iBAAiB,GAAG,MAAM,CAAC,iBAAiB,GAAG,YAAY,CAEtH"}
@@ -122,9 +122,10 @@ function handleSubscriptionCheckoutInit(session, transaction) {
122
122
  throw new Error('No subscription ID in checkout session');
123
123
  }
124
124
  const subscriptionId = session.subscription;
125
+ const stripe = stripeConfig.getStripe();
125
126
  // ===== STEP 1: FETCH EXTERNAL API DATA (BEFORE TRANSACTION) =====
126
127
  // 2. Get COMPLETE Stripe subscription details including billing period
127
- const stripeSubscription = yield stripeConfig.stripe.subscriptions.retrieve(subscriptionId);
128
+ const stripeSubscription = yield stripe.subscriptions.retrieve(subscriptionId);
128
129
  // Extract billing period from subscription items (NOT from top-level subscription object)
129
130
  // The current_period_start/end are on SubscriptionItem, not on Subscription
130
131
  const subscriptionItem = (_b = (_a = stripeSubscription.items) === null || _a === void 0 ? void 0 : _a.data) === null || _b === void 0 ? void 0 : _b[0];
@@ -343,8 +344,9 @@ function handleSubscriptionDeleted(stripeSubscription) {
343
344
  function handleAsyncPaymentSucceeded(session) {
344
345
  return tslib_es6.__awaiter(this, void 0, void 0, function* () {
345
346
  console.log(`Async payment succeeded: ${session.id}`);
347
+ const stripe = stripeConfig.getStripe();
346
348
  // Retrieve the latest session state to ensure payment_status is up to date
347
- const latestSession = yield stripeConfig.stripe.checkout.sessions.retrieve(session.id);
349
+ const latestSession = yield stripe.checkout.sessions.retrieve(session.id);
348
350
  return yield handleCheckoutCompleted(latestSession);
349
351
  });
350
352
  }
@@ -9,7 +9,7 @@ import '@prisma/client';
9
9
  import { Apilogger } from '../database/apilog.service.mjs';
10
10
  import { oneTimeExpiredDays } from '../../lib/credit-init.mjs';
11
11
  import { getCreditsFromPriceId } from '../../lib/money-price-config.mjs';
12
- import { stripe, fetchPaymentId } from '../../lib/stripe-config.mjs';
12
+ import { getStripe, fetchPaymentId } from '../../lib/stripe-config.mjs';
13
13
  import { viewLocalTime } from '@windrun-huaiin/lib/utils';
14
14
 
15
15
  /* eslint-disable @typescript-eslint/no-explicit-any */
@@ -120,6 +120,7 @@ function handleSubscriptionCheckoutInit(session, transaction) {
120
120
  throw new Error('No subscription ID in checkout session');
121
121
  }
122
122
  const subscriptionId = session.subscription;
123
+ const stripe = getStripe();
123
124
  // ===== STEP 1: FETCH EXTERNAL API DATA (BEFORE TRANSACTION) =====
124
125
  // 2. Get COMPLETE Stripe subscription details including billing period
125
126
  const stripeSubscription = yield stripe.subscriptions.retrieve(subscriptionId);
@@ -341,6 +342,7 @@ function handleSubscriptionDeleted(stripeSubscription) {
341
342
  function handleAsyncPaymentSucceeded(session) {
342
343
  return __awaiter(this, void 0, void 0, function* () {
343
344
  console.log(`Async payment succeeded: ${session.id}`);
345
+ const stripe = getStripe();
344
346
  // Retrieve the latest session state to ensure payment_status is up to date
345
347
  const latestSession = yield stripe.checkout.sessions.retrieve(session.id);
346
348
  return yield handleCheckoutCompleted(latestSession);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@windrun-huaiin/backend-core",
3
- "version": "14.0.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
 
@@ -1,10 +1,22 @@
1
1
  import Stripe from 'stripe';
2
2
  import { Apilogger, userService, subscriptionService } from '../services/database/index';
3
3
 
4
- // Stripe Configuration
5
- export const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!, {
6
- apiVersion: '2025-11-17.clover',
7
- });
4
+ let stripeInstance: Stripe | null = null;
5
+
6
+ export const getStripe = (): Stripe => {
7
+ const apiKey = process.env.STRIPE_SECRET_KEY;
8
+ if (!apiKey) {
9
+ throw new Error('STRIPE_SECRET_KEY is not configured');
10
+ }
11
+
12
+ if (!stripeInstance) {
13
+ stripeInstance = new Stripe(apiKey, {
14
+ apiVersion: '2025-11-17.clover',
15
+ });
16
+ }
17
+
18
+ return stripeInstance;
19
+ };
8
20
 
9
21
  // Helper function to validate webhook signature
10
22
  export const validateStripeWebhook = (
@@ -12,7 +24,7 @@ export const validateStripeWebhook = (
12
24
  signature: string,
13
25
  secret: string
14
26
  ): Stripe.Event => {
15
- return stripe.webhooks.constructEvent(payload, signature, secret);
27
+ return getStripe().webhooks.constructEvent(payload, signature, secret);
16
28
  };
17
29
 
18
30
  export interface BasicCheckoutSessionParams {
@@ -95,7 +107,7 @@ export const createCheckoutSession = async (
95
107
  const logId = await Apilogger.logStripeOutgoing('createCheckoutSession', params);
96
108
 
97
109
  try {
98
- const session = await stripe.checkout.sessions.create(sessionParams);
110
+ const session = await getStripe().checkout.sessions.create(sessionParams);
99
111
 
100
112
  // Update log record with response
101
113
  Apilogger.updateResponse(logId, {
@@ -116,7 +128,7 @@ export const createCheckoutSession = async (
116
128
 
117
129
  // 根据发票ID去查支付ID
118
130
  export const fetchPaymentId = async (invoiceId: string ): Promise<string> => {
119
- const fullInvoice = await stripe.invoices.retrieve(invoiceId, {
131
+ const fullInvoice = await getStripe().invoices.retrieve(invoiceId, {
120
132
  expand: ['payments']
121
133
  });
122
134
  const payment = fullInvoice.payments?.data[0];
@@ -147,7 +159,7 @@ export const createOrGetCustomer = async (params: {
147
159
 
148
160
  if (user.stripeCusId) {
149
161
  try {
150
- const customer = await stripe.customers.retrieve(user.stripeCusId);
162
+ const customer = await getStripe().customers.retrieve(user.stripeCusId);
151
163
  if ('deleted' in customer) {
152
164
  await setStripeCustomerId(null);
153
165
  } else {
@@ -164,7 +176,7 @@ export const createOrGetCustomer = async (params: {
164
176
  }
165
177
 
166
178
  if (user.email) {
167
- const existingCustomers = await stripe.customers.list({
179
+ const existingCustomers = await getStripe().customers.list({
168
180
  email: user.email,
169
181
  limit: 1,
170
182
  });
@@ -201,7 +213,7 @@ export const createOrGetCustomer = async (params: {
201
213
  });
202
214
 
203
215
  try {
204
- const customer = await stripe.customers.create(customerParams);
216
+ const customer = await getStripe().customers.create(customerParams);
205
217
  await setStripeCustomerId(customer.id);
206
218
 
207
219
  // Update log record with response
@@ -228,13 +240,13 @@ export const updateSubscription = async (params: {
228
240
  }): Promise<Stripe.Subscription> => {
229
241
  const { subscriptionId, priceId, prorationBehavior = 'create_prorations' } = params;
230
242
 
231
- const subscription = await stripe.subscriptions.retrieve(subscriptionId);
243
+ const subscription = await getStripe().subscriptions.retrieve(subscriptionId);
232
244
 
233
245
  // Create log record with request
234
246
  const logId = await Apilogger.logStripeOutgoing('updateSubscription', params);
235
247
 
236
248
  try {
237
- const updatedSubscription = await stripe.subscriptions.update(subscriptionId, {
249
+ const updatedSubscription = await getStripe().subscriptions.update(subscriptionId, {
238
250
  items: [
239
251
  {
240
252
  id: subscription.items.data[0].id,
@@ -267,7 +279,7 @@ export const createCustomerPortalSession = async (params: {
267
279
  const logId = await Apilogger.logStripeOutgoing('createCustomerPortalSession', params);
268
280
 
269
281
  try {
270
- const session = await stripe.billingPortal.sessions.create({
282
+ const session = await getStripe().billingPortal.sessions.create({
271
283
  customer: params.customerId,
272
284
  return_url: params.returnUrl,
273
285
  });
@@ -301,11 +313,11 @@ export const cancelSubscription = async (
301
313
  let result: Stripe.Subscription;
302
314
 
303
315
  if (cancelAtPeriodEnd) {
304
- result = await stripe.subscriptions.update(subscriptionId, {
316
+ result = await getStripe().subscriptions.update(subscriptionId, {
305
317
  cancel_at_period_end: true,
306
318
  });
307
319
  } else {
308
- result = await stripe.subscriptions.cancel(subscriptionId);
320
+ result = await getStripe().subscriptions.cancel(subscriptionId);
309
321
  }
310
322
 
311
323
  // Update log record with response
@@ -13,7 +13,7 @@ import {
13
13
  import { Transaction } from '@/db/prisma-model-type';
14
14
  import { oneTimeExpiredDays } from '@/lib/credit-init';
15
15
  import { getCreditsFromPriceId } from '@/lib/money-price-config';
16
- import { fetchPaymentId, stripe } from '@/lib/stripe-config';
16
+ import { fetchPaymentId, getStripe } from '@/lib/stripe-config';
17
17
  import Stripe from 'stripe';
18
18
  import { viewLocalTime } from '@windrun-huaiin/lib/utils';
19
19
 
@@ -150,6 +150,7 @@ async function handleSubscriptionCheckoutInit(
150
150
  }
151
151
 
152
152
  const subscriptionId = session.subscription as string;
153
+ const stripe = getStripe();
153
154
 
154
155
  // ===== STEP 1: FETCH EXTERNAL API DATA (BEFORE TRANSACTION) =====
155
156
  // 2. Get COMPLETE Stripe subscription details including billing period
@@ -416,6 +417,7 @@ async function handleSubscriptionDeleted(stripeSubscription: Stripe.Subscription
416
417
 
417
418
  async function handleAsyncPaymentSucceeded(session: Stripe.Checkout.Session) {
418
419
  console.log(`Async payment succeeded: ${session.id}`);
420
+ const stripe = getStripe();
419
421
 
420
422
  // Retrieve the latest session state to ensure payment_status is up to date
421
423
  const latestSession = await stripe.checkout.sessions.retrieve(session.id);