payment-kit 1.19.0 → 1.19.2

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 (139) hide show
  1. package/api/src/crons/index.ts +8 -0
  2. package/api/src/index.ts +4 -0
  3. package/api/src/libs/credit-grant.ts +146 -0
  4. package/api/src/libs/env.ts +1 -0
  5. package/api/src/libs/invoice.ts +4 -3
  6. package/api/src/libs/notification/template/base.ts +388 -2
  7. package/api/src/libs/notification/template/customer-credit-grant-granted.ts +149 -0
  8. package/api/src/libs/notification/template/customer-credit-grant-low-balance.ts +151 -0
  9. package/api/src/libs/notification/template/customer-credit-insufficient.ts +254 -0
  10. package/api/src/libs/notification/template/subscription-canceled.ts +193 -202
  11. package/api/src/libs/notification/template/subscription-refund-succeeded.ts +215 -237
  12. package/api/src/libs/notification/template/subscription-renewed.ts +130 -200
  13. package/api/src/libs/notification/template/subscription-succeeded.ts +100 -202
  14. package/api/src/libs/notification/template/subscription-trial-start.ts +142 -188
  15. package/api/src/libs/notification/template/subscription-trial-will-end.ts +146 -174
  16. package/api/src/libs/notification/template/subscription-upgraded.ts +96 -192
  17. package/api/src/libs/notification/template/subscription-will-canceled.ts +94 -135
  18. package/api/src/libs/notification/template/subscription-will-renew.ts +220 -245
  19. package/api/src/libs/payment.ts +69 -0
  20. package/api/src/libs/queue/index.ts +3 -2
  21. package/api/src/libs/session.ts +8 -0
  22. package/api/src/libs/subscription.ts +74 -3
  23. package/api/src/libs/util.ts +3 -1
  24. package/api/src/libs/ws.ts +23 -1
  25. package/api/src/locales/en.ts +33 -0
  26. package/api/src/locales/zh.ts +31 -0
  27. package/api/src/queues/credit-consume.ts +728 -0
  28. package/api/src/queues/credit-grant.ts +572 -0
  29. package/api/src/queues/notification.ts +173 -128
  30. package/api/src/queues/payment.ts +210 -122
  31. package/api/src/queues/subscription.ts +179 -0
  32. package/api/src/routes/checkout-sessions.ts +157 -9
  33. package/api/src/routes/connect/shared.ts +3 -2
  34. package/api/src/routes/credit-grants.ts +241 -0
  35. package/api/src/routes/credit-transactions.ts +208 -0
  36. package/api/src/routes/customers.ts +34 -5
  37. package/api/src/routes/index.ts +8 -0
  38. package/api/src/routes/meter-events.ts +347 -0
  39. package/api/src/routes/meters.ts +219 -0
  40. package/api/src/routes/payment-currencies.ts +20 -2
  41. package/api/src/routes/payment-links.ts +1 -1
  42. package/api/src/routes/payment-methods.ts +14 -2
  43. package/api/src/routes/prices.ts +43 -0
  44. package/api/src/routes/pricing-table.ts +13 -7
  45. package/api/src/routes/products.ts +63 -4
  46. package/api/src/routes/settings.ts +1 -1
  47. package/api/src/routes/subscriptions.ts +4 -0
  48. package/api/src/routes/webhook-endpoints.ts +0 -3
  49. package/api/src/store/migrations/20250610-billing-credit.ts +43 -0
  50. package/api/src/store/models/credit-grant.ts +486 -0
  51. package/api/src/store/models/credit-transaction.ts +268 -0
  52. package/api/src/store/models/customer.ts +8 -0
  53. package/api/src/store/models/index.ts +52 -1
  54. package/api/src/store/models/meter-event.ts +423 -0
  55. package/api/src/store/models/meter.ts +176 -0
  56. package/api/src/store/models/payment-currency.ts +66 -14
  57. package/api/src/store/models/price.ts +6 -0
  58. package/api/src/store/models/product.ts +2 -2
  59. package/api/src/store/models/subscription.ts +24 -0
  60. package/api/src/store/models/types.ts +28 -2
  61. package/api/tests/libs/subscription.spec.ts +53 -0
  62. package/blocklet.yml +9 -1
  63. package/package.json +4 -4
  64. package/scripts/sdk.js +233 -1
  65. package/src/app.tsx +10 -0
  66. package/src/components/collapse.tsx +11 -1
  67. package/src/components/conditional-section.tsx +87 -0
  68. package/src/components/customer/credit-grant-item-list.tsx +99 -0
  69. package/src/components/customer/credit-overview.tsx +246 -0
  70. package/src/components/customer/form.tsx +7 -3
  71. package/src/components/invoice/list.tsx +19 -1
  72. package/src/components/metadata/form.tsx +287 -91
  73. package/src/components/meter/actions.tsx +101 -0
  74. package/src/components/meter/add-usage-dialog.tsx +239 -0
  75. package/src/components/meter/events-list.tsx +657 -0
  76. package/src/components/meter/form.tsx +245 -0
  77. package/src/components/meter/products.tsx +264 -0
  78. package/src/components/meter/usage-guide.tsx +174 -0
  79. package/src/components/payment-currency/form.tsx +2 -0
  80. package/src/components/payment-intent/list.tsx +19 -1
  81. package/src/components/payment-link/item.tsx +2 -2
  82. package/src/components/payment-link/preview.tsx +1 -1
  83. package/src/components/payment-link/product-select.tsx +52 -12
  84. package/src/components/payment-method/arcblock.tsx +2 -0
  85. package/src/components/payment-method/base.tsx +2 -0
  86. package/src/components/payment-method/bitcoin.tsx +2 -0
  87. package/src/components/payment-method/ethereum.tsx +2 -0
  88. package/src/components/payment-method/stripe.tsx +2 -0
  89. package/src/components/payouts/list.tsx +19 -1
  90. package/src/components/payouts/portal/list.tsx +6 -11
  91. package/src/components/price/currency-select.tsx +56 -32
  92. package/src/components/price/form.tsx +912 -407
  93. package/src/components/pricing-table/preview.tsx +1 -1
  94. package/src/components/product/add-price.tsx +9 -7
  95. package/src/components/product/create.tsx +7 -4
  96. package/src/components/product/edit-price.tsx +21 -12
  97. package/src/components/product/features.tsx +17 -7
  98. package/src/components/product/form.tsx +100 -90
  99. package/src/components/refund/list.tsx +19 -1
  100. package/src/components/section/header.tsx +5 -18
  101. package/src/components/subscription/items/index.tsx +1 -1
  102. package/src/components/subscription/metrics.tsx +37 -5
  103. package/src/components/subscription/portal/actions.tsx +2 -1
  104. package/src/contexts/products.tsx +26 -9
  105. package/src/hooks/subscription.ts +34 -0
  106. package/src/libs/meter-utils.ts +196 -0
  107. package/src/libs/util.ts +4 -0
  108. package/src/locales/en.tsx +389 -5
  109. package/src/locales/zh.tsx +368 -1
  110. package/src/pages/admin/billing/index.tsx +61 -33
  111. package/src/pages/admin/billing/invoices/detail.tsx +1 -1
  112. package/src/pages/admin/billing/meters/create.tsx +60 -0
  113. package/src/pages/admin/billing/meters/detail.tsx +435 -0
  114. package/src/pages/admin/billing/meters/index.tsx +210 -0
  115. package/src/pages/admin/billing/meters/meter-event.tsx +346 -0
  116. package/src/pages/admin/billing/subscriptions/detail.tsx +47 -14
  117. package/src/pages/admin/customers/customers/credit-grant/detail.tsx +391 -0
  118. package/src/pages/admin/customers/customers/detail.tsx +14 -10
  119. package/src/pages/admin/customers/index.tsx +5 -0
  120. package/src/pages/admin/developers/events/detail.tsx +1 -1
  121. package/src/pages/admin/developers/index.tsx +1 -1
  122. package/src/pages/admin/payments/intents/detail.tsx +1 -1
  123. package/src/pages/admin/payments/payouts/detail.tsx +1 -1
  124. package/src/pages/admin/payments/refunds/detail.tsx +1 -1
  125. package/src/pages/admin/products/index.tsx +3 -2
  126. package/src/pages/admin/products/links/detail.tsx +1 -1
  127. package/src/pages/admin/products/prices/actions.tsx +16 -4
  128. package/src/pages/admin/products/prices/detail.tsx +30 -3
  129. package/src/pages/admin/products/prices/list.tsx +8 -1
  130. package/src/pages/admin/products/pricing-tables/detail.tsx +1 -1
  131. package/src/pages/admin/products/products/create.tsx +233 -57
  132. package/src/pages/admin/products/products/detail.tsx +2 -1
  133. package/src/pages/admin/settings/payment-methods/index.tsx +3 -0
  134. package/src/pages/customer/credit-grant/detail.tsx +308 -0
  135. package/src/pages/customer/index.tsx +44 -9
  136. package/src/pages/customer/recharge/account.tsx +5 -5
  137. package/src/pages/customer/subscription/change-payment.tsx +4 -2
  138. package/src/pages/customer/subscription/detail.tsx +48 -14
  139. package/src/pages/customer/subscription/embed.tsx +1 -1
@@ -38,7 +38,7 @@ import {
38
38
  SubscriptionTrialStartEmailTemplateOptions,
39
39
  } from '../libs/notification/template/subscription-trial-start';
40
40
  import {
41
- SubscriptionTrialWilEndEmailTemplate,
41
+ SubscriptionTrialWillEndEmailTemplate,
42
42
  SubscriptionTrialWillEndEmailTemplateOptions,
43
43
  } from '../libs/notification/template/subscription-trial-will-end';
44
44
  import {
@@ -67,6 +67,7 @@ import {
67
67
  Refund,
68
68
  Subscription,
69
69
  Customer,
70
+ CreditGrant,
70
71
  } from '../store/models';
71
72
  import {
72
73
  UsageReportEmptyEmailTemplate,
@@ -80,6 +81,18 @@ import {
80
81
  OverdraftProtectionExhaustedEmailTemplate,
81
82
  OverdraftProtectionExhaustedEmailTemplateOptions,
82
83
  } from '../libs/notification/template/subscription-overdraft-protection-exhausted';
84
+ import {
85
+ CustomerCreditInsufficientEmailTemplate,
86
+ CustomerCreditInsufficientEmailTemplateOptions,
87
+ } from '../libs/notification/template/customer-credit-insufficient';
88
+ import {
89
+ CustomerCreditGrantGrantedEmailTemplate,
90
+ CustomerCreditGrantGrantedEmailTemplateOptions,
91
+ } from '../libs/notification/template/customer-credit-grant-granted';
92
+ import {
93
+ CustomerCreditGrantLowBalanceEmailTemplate,
94
+ CustomerCreditGrantLowBalanceEmailTemplateOptions,
95
+ } from '../libs/notification/template/customer-credit-grant-low-balance';
83
96
  import {
84
97
  CustomerRevenueSucceededEmailTemplate,
85
98
  CustomerRevenueSucceededEmailTemplateOptions,
@@ -108,7 +121,10 @@ export type NotificationQueueJobType =
108
121
  | 'customer.reward.succeeded'
109
122
  | 'usage.report.empty'
110
123
  | 'billing.discrepancy'
111
- | 'subscription.overdraftProtection.exhausted';
124
+ | 'subscription.overdraftProtection.exhausted'
125
+ | 'customer.credit.insufficient'
126
+ | 'customer.credit_grant.granted'
127
+ | 'customer.credit_grant.low_balance';
112
128
 
113
129
  export type NotificationQueueJob = {
114
130
  type: NotificationQueueJobType;
@@ -200,7 +216,7 @@ function getNotificationTemplate(job: NotificationQueueJob): BaseEmailTemplate {
200
216
  return new SubscriptionWillRenewEmailTemplate(job.options as SubscriptionWillRenewEmailTemplateOptions);
201
217
  }
202
218
  if (job.type === 'customer.subscription.trial_will_end') {
203
- return new SubscriptionTrialWilEndEmailTemplate(job.options as SubscriptionTrialWillEndEmailTemplateOptions);
219
+ return new SubscriptionTrialWillEndEmailTemplate(job.options as SubscriptionTrialWillEndEmailTemplateOptions);
204
220
  }
205
221
  if (job.type === 'customer.subscription.will_canceled') {
206
222
  return new SubscriptionWillCanceledEmailTemplate(job.options as SubscriptionWillCanceledEmailTemplateOptions);
@@ -229,6 +245,20 @@ function getNotificationTemplate(job: NotificationQueueJob): BaseEmailTemplate {
229
245
  return new CustomerRevenueSucceededEmailTemplate(job.options as CustomerRevenueSucceededEmailTemplateOptions);
230
246
  }
231
247
 
248
+ if (job.type === 'customer.credit.insufficient') {
249
+ return new CustomerCreditInsufficientEmailTemplate(job.options as CustomerCreditInsufficientEmailTemplateOptions);
250
+ }
251
+
252
+ if (job.type === 'customer.credit_grant.granted') {
253
+ return new CustomerCreditGrantGrantedEmailTemplate(job.options as CustomerCreditGrantGrantedEmailTemplateOptions);
254
+ }
255
+
256
+ if (job.type === 'customer.credit_grant.low_balance') {
257
+ return new CustomerCreditGrantLowBalanceEmailTemplate(
258
+ job.options as CustomerCreditGrantLowBalanceEmailTemplateOptions
259
+ );
260
+ }
261
+
232
262
  throw new Error(`Unknown job type: ${job.type}`);
233
263
  }
234
264
 
@@ -252,24 +282,28 @@ interface NotificationItem<T = Record<string, any>> {
252
282
  data: T;
253
283
  }
254
284
 
285
+ // 内存缓存,记录最近发送的通知
286
+ const notificationCache = new Map<string, number>();
287
+
288
+ // 清理过期缓存的定时器
289
+ setInterval(
290
+ () => {
291
+ const now = Date.now();
292
+ for (const [key, timestamp] of notificationCache.entries()) {
293
+ // 清理超过24小时的缓存记录
294
+ if (now - timestamp > 24 * 60 * 60 * 1000) {
295
+ notificationCache.delete(key);
296
+ }
297
+ }
298
+ },
299
+ 60 * 60 * 1000
300
+ ); // 每小时清理一次
301
+
255
302
  /**
256
303
  * Handles immediate notifications by pushing them directly to the notification queue
257
304
  */
258
305
  function handleImmediateNotification(type: string, data: Record<string, any>, extraIds: string[] = []) {
259
- const idParts = [type];
260
- if (extraIds.length) {
261
- idParts.push(...extraIds);
262
- } else {
263
- idParts.push(Date.now().toString());
264
- }
265
-
266
- return notificationQueue.push({
267
- id: idParts.join('.'),
268
- job: {
269
- type,
270
- options: data,
271
- },
272
- });
306
+ return addNotificationJob(type as NotificationQueueJobType, data, extraIds);
273
307
  }
274
308
 
275
309
  /**
@@ -423,29 +457,13 @@ export const aggregatedNotificationQueue = createQueue<AggregatedNotificationJob
423
457
  export async function startNotificationQueue() {
424
458
  // 试用期开始
425
459
  events.on('customer.subscription.trial_start', (subscription: Subscription) => {
426
- notificationQueue.push({
427
- id: `customer.subscription.trial_start.${subscription.id}`,
428
- job: {
429
- type: 'customer.subscription.trial_start',
430
- options: {
431
- subscriptionId: subscription.id,
432
- },
433
- },
434
- });
460
+ addNotificationJob('customer.subscription.trial_start', { subscriptionId: subscription.id }, [subscription.id]);
435
461
  });
436
462
 
437
463
  events.on('customer.subscription.started', (subscription: Subscription) => {
438
464
  if (!subscription.trial_start) {
439
465
  // 没有试用期的 subscription 通知
440
- notificationQueue.push({
441
- id: `customer.subscription.started.${subscription.id}`,
442
- job: {
443
- type: 'customer.subscription.started',
444
- options: {
445
- subscriptionId: subscription.id,
446
- },
447
- },
448
- });
466
+ addNotificationJob('customer.subscription.started', { subscriptionId: subscription.id }, [subscription.id]);
449
467
  }
450
468
  });
451
469
 
@@ -454,27 +472,13 @@ export async function startNotificationQueue() {
454
472
  if (checkoutSession.mode === 'payment') {
455
473
  const paymentLink = await PaymentLink.findByPk(checkoutSession.payment_link_id);
456
474
  if (paymentLink?.submit_type === 'donate') {
457
- notificationQueue.push({
458
- id: `customer.reward.succeeded.${checkoutSession.id}`,
459
- job: {
460
- type: 'customer.reward.succeeded',
461
- options: {
462
- checkoutSessionId: checkoutSession.id,
463
- },
464
- },
465
- });
475
+ addNotificationJob('customer.reward.succeeded', { checkoutSessionId: checkoutSession.id }, [
476
+ checkoutSession.id,
477
+ ]);
466
478
  return;
467
479
  }
468
480
 
469
- notificationQueue.push({
470
- id: `checkout.session.completed.${checkoutSession.id}`,
471
- job: {
472
- type: 'checkout.session.completed',
473
- options: {
474
- checkoutSessionId: checkoutSession.id,
475
- },
476
- },
477
- });
481
+ addNotificationJob('checkout.session.completed', { checkoutSessionId: checkoutSession.id }, [checkoutSession.id]);
478
482
  }
479
483
  });
480
484
 
@@ -523,106 +527,95 @@ export async function startNotificationQueue() {
523
527
  });
524
528
 
525
529
  if (invoice && subscription.metadata.renew_failed_reason) {
526
- notificationQueue.push({
527
- id: `customer.subscription.renew_failed.${subscription.id}.${invoice.id}`,
528
- job: {
529
- type: 'customer.subscription.renew_failed',
530
- options: {
531
- invoice,
532
- result: subscription.metadata.renew_failed_reason,
533
- },
534
- },
535
- });
530
+ addNotificationJob(
531
+ 'customer.subscription.renew_failed',
532
+ { invoice, result: subscription.metadata.renew_failed_reason },
533
+ [subscription.id, invoice.id]
534
+ );
536
535
  }
537
536
  });
538
537
 
539
538
  events.on('customer.subscription.upgraded', (subscription: Subscription) => {
540
- notificationQueue.push({
541
- id: `customer.subscription.upgraded.${subscription.id}.${subscription.latest_invoice_id}`,
542
- job: {
543
- type: 'customer.subscription.upgraded',
544
- options: {
545
- subscriptionId: subscription.id,
546
- },
547
- },
548
- });
539
+ const extraIds = [subscription.id];
540
+ if (subscription.latest_invoice_id) {
541
+ extraIds.push(subscription.latest_invoice_id);
542
+ }
543
+ addNotificationJob('customer.subscription.upgraded', { subscriptionId: subscription.id }, extraIds);
549
544
  });
550
545
 
551
546
  events.on('refund.succeeded', (refund: Refund) => {
552
- notificationQueue.push({
553
- id: `refund.succeeded.${refund.subscription_id}.${refund.id}`,
554
- job: {
555
- type: 'refund.succeeded',
556
- options: {
557
- refundId: refund.id,
558
- },
559
- },
560
- });
547
+ const extraIds = [refund.id];
548
+ if (refund.subscription_id) {
549
+ extraIds.unshift(refund.subscription_id);
550
+ }
551
+ addNotificationJob('refund.succeeded', { refundId: refund.id }, extraIds);
561
552
  });
562
553
 
563
554
  events.on('customer.subscription.deleted', (subscription: Subscription) => {
564
- notificationQueue.push({
565
- id: `customer.subscription.deleted.${subscription.id}`,
566
- job: {
567
- type: 'customer.subscription.deleted',
568
- options: {
569
- subscriptionId: subscription.id,
570
- },
571
- },
572
- });
555
+ addNotificationJob('customer.subscription.deleted', { subscriptionId: subscription.id }, [subscription.id]);
573
556
  });
574
557
 
575
558
  events.on('usage.report.empty', (subscription: Subscription, { usageReportStart, usageReportEnd }) => {
576
- notificationQueue.push({
577
- id: `usage.report.empty.${subscription.id}`,
578
- job: {
579
- type: 'usage.report.empty',
580
- options: {
581
- subscriptionId: subscription.id,
582
- usageReportStart,
583
- usageReportEnd,
584
- },
585
- },
586
- });
559
+ addNotificationJob('usage.report.empty', { subscriptionId: subscription.id, usageReportStart, usageReportEnd }, [
560
+ subscription.id,
561
+ ]);
587
562
  });
588
563
 
589
564
  events.on('billing.discrepancy', (invoice: Invoice) => {
590
- notificationQueue.push({
591
- id: `billing.discrepancy.${invoice.id}`,
592
- job: {
593
- type: 'billing.discrepancy',
594
- options: {
595
- invoiceId: invoice.id,
596
- },
597
- },
598
- });
565
+ addNotificationJob('billing.discrepancy', { invoiceId: invoice.id }, [invoice.id]);
599
566
  });
600
567
 
601
568
  events.on('subscription.overdraft_protection.exhausted', (subscription: Subscription) => {
602
- notificationQueue.push({
603
- id: `subscription.overdraftProtection.exhausted.${subscription.id}`,
604
- job: {
605
- type: 'subscription.overdraftProtection.exhausted',
606
- options: {
607
- subscriptionId: subscription.id,
608
- },
609
- },
610
- });
569
+ addNotificationJob('subscription.overdraftProtection.exhausted', { subscriptionId: subscription.id }, [
570
+ subscription.id,
571
+ ]);
611
572
  });
612
573
 
613
574
  events.on('payout.paid', (payout: Payout) => {
614
575
  if (payout.customer_id) {
615
- notificationQueue.push({
616
- id: `payout.paid.${payout.id}`,
617
- job: {
618
- type: 'payout.paid',
619
- options: {
620
- payoutId: payout.id,
621
- },
622
- },
623
- });
576
+ addNotificationJob('payout.paid', { payoutId: payout.id }, [payout.id]);
624
577
  }
625
578
  });
579
+
580
+ events.on('customer.credit_grant.granted', (creditGrant: CreditGrant) => {
581
+ addNotificationJob(
582
+ 'customer.credit_grant.granted',
583
+ {
584
+ creditGrantId: creditGrant.id,
585
+ },
586
+ [creditGrant.id]
587
+ );
588
+ });
589
+
590
+ events.on('customer.credit_grant.low_balance', (creditGrant: CreditGrant) => {
591
+ addNotificationJob(
592
+ 'customer.credit_grant.low_balance',
593
+ {
594
+ creditGrantId: creditGrant.id,
595
+ },
596
+ [creditGrant.id],
597
+ true,
598
+ 24 * 3600 // 1 天
599
+ );
600
+ });
601
+
602
+ events.on('customer.credit.insufficient', (customer: Customer, { metadata }: { metadata: any }) => {
603
+ addNotificationJob(
604
+ 'customer.credit.insufficient',
605
+ {
606
+ customerId: customer.id,
607
+ currencyId: metadata.currency_id,
608
+ meterEventName: metadata.meter_event_name || 'Service',
609
+ requiredAmount: metadata.required_amount,
610
+ availableAmount: metadata.available_amount,
611
+ pendingAmount: metadata.pending_amount || '0',
612
+ subscriptionId: metadata.subscription_id,
613
+ },
614
+ [customer.id, metadata.currency_id, metadata.subscription_id],
615
+ true,
616
+ 600
617
+ );
618
+ });
626
619
  }
627
620
 
628
621
  export async function handleNotificationPreferenceChange(
@@ -705,3 +698,55 @@ export async function handleNotificationPreferenceChange(
705
698
  /* eslint-enable no-await-in-loop */
706
699
  }
707
700
  }
701
+
702
+ /**
703
+ * Add a notification job to the queue
704
+ * @param type Notification type
705
+ * @param options Notification options
706
+ * @param extraIds Extra IDs for building a unique job ID, if not provided, use timestamp
707
+ * @param preventDuplicate Whether to prevent duplicate, if true, will check the cache
708
+ * @param duplicateWindow Duplicate prevention time window (seconds), default 600 seconds (10 minutes)
709
+ */
710
+ export function addNotificationJob(
711
+ type: NotificationQueueJobType,
712
+ options: NotificationQueueJobOptions,
713
+ extraIds: string[] = [],
714
+ preventDuplicate: boolean = false,
715
+ duplicateWindow: number = 600 // 10分钟
716
+ ) {
717
+ const idParts = [type];
718
+
719
+ if (extraIds.length) {
720
+ idParts.push(...extraIds);
721
+ } else {
722
+ idParts.push(Date.now().toString());
723
+ }
724
+
725
+ // if prevent duplicate, check the cache
726
+ if (preventDuplicate) {
727
+ const cacheKey = `${type}.${extraIds.join('.')}`;
728
+ const lastSent = notificationCache.get(cacheKey);
729
+ const now = Date.now();
730
+
731
+ if (lastSent && now - lastSent < duplicateWindow * 1000) {
732
+ logger.info('Notification skipped due to duplicate prevention', {
733
+ type,
734
+ cacheKey,
735
+ lastSent: new Date(lastSent),
736
+ duplicateWindow,
737
+ });
738
+ return Promise.resolve(); // 跳过重复通知
739
+ }
740
+
741
+ // 记录本次发送时间
742
+ notificationCache.set(cacheKey, now);
743
+ }
744
+
745
+ return notificationQueue.push({
746
+ id: idParts.join('.'),
747
+ job: {
748
+ type,
749
+ options,
750
+ },
751
+ });
752
+ }