create-pardx-scaffold 0.1.10 → 0.1.11
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/package.json +1 -1
- package/template/apps/api/libs/domain/auth/src/auth.service.ts +27 -1
- package/template/apps/api/libs/infra/clients/internal/email/dto/email.dto.ts +3 -3
- package/template/apps/api/libs/infra/clients/internal/volcengine-tts/dto/tts.dto.ts +4 -4
- package/template/apps/api/libs/infra/common/common.module.ts +10 -0
- package/template/apps/api/libs/infra/common/config/validation/env.validation.ts +1 -1
- package/template/apps/api/libs/infra/common/config/validation/keys.validation.ts +2 -2
- package/template/apps/api/libs/infra/common/config/validation/yaml.validation.ts +3 -3
- package/template/apps/api/libs/infra/common/decorators/device-info.decorator.ts +58 -0
- package/template/apps/api/libs/infra/common/decorators/team-info.decorator.ts +122 -0
- package/template/apps/api/libs/infra/common/encryption.service.ts +70 -0
- package/template/apps/api/libs/infra/common/index.ts +9 -0
- package/template/apps/api/libs/infra/shared-services/email/dto/email.dto.ts +3 -3
- package/template/apps/api/libs/infra/shared-services/email/email.service.ts +5 -1
- package/template/apps/api/libs/infra/shared-services/notification/index.ts +13 -0
- package/template/apps/api/libs/infra/shared-services/notification/notification.module.ts +10 -0
- package/template/apps/api/libs/infra/shared-services/notification/notification.service.ts +791 -0
- package/template/apps/web/components/client-only.tsx +28 -0
- package/template/apps/web/components/index.ts +23 -0
- package/template/apps/web/components/layout/app-navbar.tsx +109 -0
- package/template/apps/web/components/layout/app-shell.tsx +30 -0
- package/template/apps/web/components/layout/app-sidebar.tsx +206 -0
- package/template/apps/web/components/layout/index.ts +4 -0
- package/template/apps/web/components/layout/locale-switcher.tsx +57 -0
- package/template/apps/web/components/runtime-i18n-bridge.tsx +32 -0
- package/template/apps/web/components/state-components.tsx +214 -0
- package/template/apps/web/config.ts +22 -2
- package/template/apps/web/lib/api/cache-config.ts +32 -0
- package/template/apps/web/lib/api/contracts/client.ts +43 -1
- package/template/apps/web/lib/api/contracts/hooks/analytics.ts +32 -0
- package/template/apps/web/lib/api/contracts/hooks/index.ts +41 -2
- package/template/apps/web/lib/api/contracts/hooks/message.ts +60 -0
- package/template/apps/web/lib/api/contracts/hooks/system.ts +42 -0
- package/template/apps/web/lib/api/contracts/hooks/task.ts +54 -0
- package/template/apps/web/lib/api/contracts/hooks/user.ts +45 -0
- package/template/apps/web/lib/api/contracts/server-client.ts +1 -1
- package/template/apps/web/lib/api/prefetch.ts +128 -0
- package/template/apps/web/lib/api/query-client.ts +37 -0
- package/template/apps/web/lib/i18n/runtime-translator.ts +48 -0
- package/template/apps/web/lib/requests.ts +1 -1
- package/template/apps/web/providers/app-provider.tsx +1 -1
- package/template/apps/web/providers/auth-provider.tsx +228 -0
- package/template/apps/web/providers/index.tsx +28 -9
- package/template/apps/web/providers/intl-client-provider.tsx +43 -0
- package/template/apps/web/vitest.config.ts +4 -0
|
@@ -0,0 +1,791 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* NotificationService - 通知服务
|
|
3
|
+
*
|
|
4
|
+
* 负责:
|
|
5
|
+
* - 多渠道通知发送(邮件、Webhook、Slack)
|
|
6
|
+
* - 通知模板管理
|
|
7
|
+
* - 通知历史记录
|
|
8
|
+
* - 通知偏好设置
|
|
9
|
+
*/
|
|
10
|
+
import { Inject, Injectable, OnModuleInit } from '@nestjs/common';
|
|
11
|
+
import { WINSTON_MODULE_PROVIDER } from 'nest-winston';
|
|
12
|
+
import { Logger } from 'winston';
|
|
13
|
+
import { EventEmitter2 } from '@nestjs/event-emitter';
|
|
14
|
+
import { HttpService } from '@nestjs/axios';
|
|
15
|
+
import { firstValueFrom } from 'rxjs';
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* 通知渠道
|
|
19
|
+
*/
|
|
20
|
+
export type NotificationChannel = 'email' | 'webhook' | 'slack' | 'wechat';
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* 通知优先级
|
|
24
|
+
*/
|
|
25
|
+
export type NotificationPriority = 'low' | 'normal' | 'high' | 'critical';
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* 通知状态
|
|
29
|
+
*/
|
|
30
|
+
export type NotificationStatus = 'pending' | 'sent' | 'failed' | 'retrying';
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* 通知请求
|
|
34
|
+
*/
|
|
35
|
+
export interface NotificationRequest {
|
|
36
|
+
id?: string;
|
|
37
|
+
channel: NotificationChannel;
|
|
38
|
+
recipients: string[];
|
|
39
|
+
subject?: string;
|
|
40
|
+
content: string;
|
|
41
|
+
htmlContent?: string;
|
|
42
|
+
priority?: NotificationPriority;
|
|
43
|
+
metadata?: Record<string, unknown>;
|
|
44
|
+
templateId?: string;
|
|
45
|
+
templateData?: Record<string, unknown>;
|
|
46
|
+
scheduledAt?: Date;
|
|
47
|
+
expiresAt?: Date;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* 通知记录
|
|
52
|
+
*/
|
|
53
|
+
export interface NotificationRecord {
|
|
54
|
+
id: string;
|
|
55
|
+
requestId: string;
|
|
56
|
+
channel: NotificationChannel;
|
|
57
|
+
recipient: string;
|
|
58
|
+
subject?: string;
|
|
59
|
+
content: string;
|
|
60
|
+
status: NotificationStatus;
|
|
61
|
+
priority: NotificationPriority;
|
|
62
|
+
attempts: number;
|
|
63
|
+
maxAttempts: number;
|
|
64
|
+
lastAttemptAt?: Date;
|
|
65
|
+
sentAt?: Date;
|
|
66
|
+
failedAt?: Date;
|
|
67
|
+
errorMessage?: string;
|
|
68
|
+
metadata?: Record<string, unknown>;
|
|
69
|
+
createdAt: Date;
|
|
70
|
+
updatedAt: Date;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* 通知模板
|
|
75
|
+
*/
|
|
76
|
+
export interface NotificationTemplate {
|
|
77
|
+
id: string;
|
|
78
|
+
name: string;
|
|
79
|
+
channel: NotificationChannel;
|
|
80
|
+
subject?: string;
|
|
81
|
+
content: string;
|
|
82
|
+
htmlContent?: string;
|
|
83
|
+
variables: string[];
|
|
84
|
+
isEnabled: boolean;
|
|
85
|
+
createdAt: Date;
|
|
86
|
+
updatedAt: Date;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* 通知偏好设置
|
|
91
|
+
*/
|
|
92
|
+
export interface NotificationPreferences {
|
|
93
|
+
id: string;
|
|
94
|
+
userId?: string;
|
|
95
|
+
tenantId?: string;
|
|
96
|
+
channels: {
|
|
97
|
+
email: { enabled: boolean; address?: string };
|
|
98
|
+
webhook: { enabled: boolean; url?: string };
|
|
99
|
+
slack: { enabled: boolean; webhookUrl?: string };
|
|
100
|
+
wechat: { enabled: boolean; webhookUrl?: string };
|
|
101
|
+
};
|
|
102
|
+
quietHours?: {
|
|
103
|
+
enabled: boolean;
|
|
104
|
+
start: string; // HH:mm
|
|
105
|
+
end: string; // HH:mm
|
|
106
|
+
timezone: string;
|
|
107
|
+
};
|
|
108
|
+
priorities: {
|
|
109
|
+
low: boolean;
|
|
110
|
+
normal: boolean;
|
|
111
|
+
high: boolean;
|
|
112
|
+
critical: boolean;
|
|
113
|
+
};
|
|
114
|
+
createdAt: Date;
|
|
115
|
+
updatedAt: Date;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* 通知统计
|
|
120
|
+
*/
|
|
121
|
+
export interface NotificationStats {
|
|
122
|
+
total: number;
|
|
123
|
+
sent: number;
|
|
124
|
+
failed: number;
|
|
125
|
+
pending: number;
|
|
126
|
+
byChannel: Record<NotificationChannel, { sent: number; failed: number }>;
|
|
127
|
+
byPriority: Record<NotificationPriority, number>;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Webhook 负载
|
|
132
|
+
*/
|
|
133
|
+
export interface WebhookPayload {
|
|
134
|
+
event: string;
|
|
135
|
+
timestamp: string;
|
|
136
|
+
data: Record<string, unknown>;
|
|
137
|
+
signature?: string;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
@Injectable()
|
|
141
|
+
export class NotificationService implements OnModuleInit {
|
|
142
|
+
// 内存存储
|
|
143
|
+
private templates = new Map<string, NotificationTemplate>();
|
|
144
|
+
private preferences = new Map<string, NotificationPreferences>();
|
|
145
|
+
private records = new Map<string, NotificationRecord>();
|
|
146
|
+
private readonly MAX_RECORDS = 10000;
|
|
147
|
+
|
|
148
|
+
// 重试配置
|
|
149
|
+
private readonly MAX_RETRY_ATTEMPTS = 3;
|
|
150
|
+
private readonly RETRY_DELAYS_MS = [1000, 5000, 15000];
|
|
151
|
+
|
|
152
|
+
constructor(
|
|
153
|
+
@Inject(WINSTON_MODULE_PROVIDER) private readonly logger: Logger,
|
|
154
|
+
private readonly eventEmitter: EventEmitter2,
|
|
155
|
+
private readonly httpService: HttpService,
|
|
156
|
+
) {}
|
|
157
|
+
|
|
158
|
+
async onModuleInit(): Promise<void> {
|
|
159
|
+
this.logger.info('[Notification] Service initialized');
|
|
160
|
+
this.initializeDefaultTemplates();
|
|
161
|
+
this.startRetryProcessor();
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// ==================== 发送通知 ====================
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* 发送通知
|
|
168
|
+
*/
|
|
169
|
+
async send(request: NotificationRequest): Promise<{
|
|
170
|
+
success: boolean;
|
|
171
|
+
records: NotificationRecord[];
|
|
172
|
+
errors?: string[];
|
|
173
|
+
}> {
|
|
174
|
+
const requestId = request.id || this.generateId('req');
|
|
175
|
+
const records: NotificationRecord[] = [];
|
|
176
|
+
const errors: string[] = [];
|
|
177
|
+
|
|
178
|
+
// 应用模板(如果指定)
|
|
179
|
+
let content = request.content;
|
|
180
|
+
let htmlContent = request.htmlContent;
|
|
181
|
+
let subject = request.subject;
|
|
182
|
+
|
|
183
|
+
if (request.templateId) {
|
|
184
|
+
const template = this.templates.get(request.templateId);
|
|
185
|
+
if (template) {
|
|
186
|
+
content = this.renderTemplate(
|
|
187
|
+
template.content,
|
|
188
|
+
request.templateData || {},
|
|
189
|
+
);
|
|
190
|
+
htmlContent = template.htmlContent
|
|
191
|
+
? this.renderTemplate(template.htmlContent, request.templateData || {})
|
|
192
|
+
: undefined;
|
|
193
|
+
subject = template.subject
|
|
194
|
+
? this.renderTemplate(template.subject, request.templateData || {})
|
|
195
|
+
: subject;
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
// 检查静默时段
|
|
200
|
+
if (this.isQuietHours(request.metadata?.userId as string)) {
|
|
201
|
+
this.logger.info('[Notification] Skipping due to quiet hours', {
|
|
202
|
+
requestId,
|
|
203
|
+
});
|
|
204
|
+
return { success: true, records: [], errors: ['Quiet hours'] };
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
// 发送到每个接收者
|
|
208
|
+
for (const recipient of request.recipients) {
|
|
209
|
+
const record = await this.sendToRecipient(
|
|
210
|
+
requestId,
|
|
211
|
+
request.channel,
|
|
212
|
+
recipient,
|
|
213
|
+
subject,
|
|
214
|
+
content,
|
|
215
|
+
htmlContent,
|
|
216
|
+
request.priority || 'normal',
|
|
217
|
+
request.metadata,
|
|
218
|
+
);
|
|
219
|
+
records.push(record);
|
|
220
|
+
|
|
221
|
+
if (record.status === 'failed') {
|
|
222
|
+
errors.push(`Failed to send to ${recipient}: ${record.errorMessage}`);
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
const success = records.some((r) => r.status === 'sent');
|
|
227
|
+
|
|
228
|
+
return { success, records, errors: errors.length > 0 ? errors : undefined };
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/**
|
|
232
|
+
* 发送到单个接收者
|
|
233
|
+
*/
|
|
234
|
+
private async sendToRecipient(
|
|
235
|
+
requestId: string,
|
|
236
|
+
channel: NotificationChannel,
|
|
237
|
+
recipient: string,
|
|
238
|
+
subject?: string,
|
|
239
|
+
content?: string,
|
|
240
|
+
htmlContent?: string,
|
|
241
|
+
priority: NotificationPriority = 'normal',
|
|
242
|
+
metadata?: Record<string, unknown>,
|
|
243
|
+
): Promise<NotificationRecord> {
|
|
244
|
+
const recordId = this.generateId('rec');
|
|
245
|
+
const now = new Date();
|
|
246
|
+
|
|
247
|
+
const record: NotificationRecord = {
|
|
248
|
+
id: recordId,
|
|
249
|
+
requestId,
|
|
250
|
+
channel,
|
|
251
|
+
recipient,
|
|
252
|
+
subject,
|
|
253
|
+
content: content || '',
|
|
254
|
+
status: 'pending',
|
|
255
|
+
priority,
|
|
256
|
+
attempts: 0,
|
|
257
|
+
maxAttempts: this.MAX_RETRY_ATTEMPTS,
|
|
258
|
+
metadata,
|
|
259
|
+
createdAt: now,
|
|
260
|
+
updatedAt: now,
|
|
261
|
+
};
|
|
262
|
+
|
|
263
|
+
try {
|
|
264
|
+
let success = false;
|
|
265
|
+
|
|
266
|
+
switch (channel) {
|
|
267
|
+
case 'email':
|
|
268
|
+
success = await this.sendEmail(
|
|
269
|
+
recipient,
|
|
270
|
+
subject || '',
|
|
271
|
+
content || '',
|
|
272
|
+
htmlContent,
|
|
273
|
+
);
|
|
274
|
+
break;
|
|
275
|
+
case 'webhook':
|
|
276
|
+
success = await this.sendWebhook(
|
|
277
|
+
recipient,
|
|
278
|
+
(metadata?.event as string) || 'notification',
|
|
279
|
+
(metadata?.data as Record<string, unknown>) || {},
|
|
280
|
+
);
|
|
281
|
+
break;
|
|
282
|
+
case 'slack':
|
|
283
|
+
success = await this.sendSlack(
|
|
284
|
+
recipient,
|
|
285
|
+
content || '',
|
|
286
|
+
metadata?.slackBlocks,
|
|
287
|
+
);
|
|
288
|
+
break;
|
|
289
|
+
case 'wechat':
|
|
290
|
+
success = await this.sendWechat(recipient, content || '');
|
|
291
|
+
break;
|
|
292
|
+
default:
|
|
293
|
+
throw new Error(`Unsupported channel: ${channel}`);
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
record.status = 'sent';
|
|
297
|
+
record.sentAt = new Date();
|
|
298
|
+
record.attempts++;
|
|
299
|
+
|
|
300
|
+
this.logger.info('[Notification] Sent successfully', {
|
|
301
|
+
recordId,
|
|
302
|
+
channel,
|
|
303
|
+
recipient,
|
|
304
|
+
});
|
|
305
|
+
} catch (error) {
|
|
306
|
+
record.status = 'failed';
|
|
307
|
+
record.failedAt = new Date();
|
|
308
|
+
record.attempts++;
|
|
309
|
+
record.errorMessage =
|
|
310
|
+
error instanceof Error ? error.message : String(error);
|
|
311
|
+
|
|
312
|
+
this.logger.error('[Notification] Send failed', {
|
|
313
|
+
recordId,
|
|
314
|
+
channel,
|
|
315
|
+
recipient,
|
|
316
|
+
error: record.errorMessage,
|
|
317
|
+
});
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
record.lastAttemptAt = new Date();
|
|
321
|
+
record.updatedAt = new Date();
|
|
322
|
+
|
|
323
|
+
// 存储记录
|
|
324
|
+
this.storeRecord(record);
|
|
325
|
+
|
|
326
|
+
return record;
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
/**
|
|
330
|
+
* 发送邮件
|
|
331
|
+
*/
|
|
332
|
+
private async sendEmail(
|
|
333
|
+
to: string,
|
|
334
|
+
subject: string,
|
|
335
|
+
content: string,
|
|
336
|
+
htmlContent?: string,
|
|
337
|
+
): Promise<boolean> {
|
|
338
|
+
// 这里应该调用实际的邮件服务
|
|
339
|
+
// 目前只是模拟
|
|
340
|
+
this.logger.debug('[Notification] Sending email', { to, subject });
|
|
341
|
+
return true;
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
/**
|
|
345
|
+
* 发送 Webhook
|
|
346
|
+
*/
|
|
347
|
+
private async sendWebhook(
|
|
348
|
+
url: string,
|
|
349
|
+
event: string,
|
|
350
|
+
data: Record<string, unknown>,
|
|
351
|
+
): Promise<boolean> {
|
|
352
|
+
try {
|
|
353
|
+
const payload: WebhookPayload = {
|
|
354
|
+
event,
|
|
355
|
+
timestamp: new Date().toISOString(),
|
|
356
|
+
data,
|
|
357
|
+
};
|
|
358
|
+
|
|
359
|
+
const response = await firstValueFrom(
|
|
360
|
+
this.httpService.post(url, payload, {
|
|
361
|
+
headers: {
|
|
362
|
+
'Content-Type': 'application/json',
|
|
363
|
+
'X-Notification-Event': event,
|
|
364
|
+
},
|
|
365
|
+
timeout: 10000,
|
|
366
|
+
}),
|
|
367
|
+
);
|
|
368
|
+
|
|
369
|
+
return response.status >= 200 && response.status < 300;
|
|
370
|
+
} catch (error) {
|
|
371
|
+
this.logger.error('[Notification] Webhook failed', {
|
|
372
|
+
url,
|
|
373
|
+
error: error instanceof Error ? error.message : String(error),
|
|
374
|
+
});
|
|
375
|
+
return false;
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
/**
|
|
380
|
+
* 发送 Slack 消息
|
|
381
|
+
*/
|
|
382
|
+
private async sendSlack(
|
|
383
|
+
webhookUrl: string,
|
|
384
|
+
content: string,
|
|
385
|
+
blocks?: unknown,
|
|
386
|
+
): Promise<boolean> {
|
|
387
|
+
try {
|
|
388
|
+
const payload = {
|
|
389
|
+
text: content,
|
|
390
|
+
blocks: blocks || undefined,
|
|
391
|
+
};
|
|
392
|
+
|
|
393
|
+
const response = await firstValueFrom(
|
|
394
|
+
this.httpService.post(webhookUrl, payload, {
|
|
395
|
+
headers: { 'Content-Type': 'application/json' },
|
|
396
|
+
timeout: 10000,
|
|
397
|
+
}),
|
|
398
|
+
);
|
|
399
|
+
|
|
400
|
+
return response.status === 200;
|
|
401
|
+
} catch (error) {
|
|
402
|
+
this.logger.error('[Notification] Slack failed', {
|
|
403
|
+
webhookUrl,
|
|
404
|
+
error: error instanceof Error ? error.message : String(error),
|
|
405
|
+
});
|
|
406
|
+
return false;
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
/**
|
|
411
|
+
* 发送企业微信消息
|
|
412
|
+
*/
|
|
413
|
+
private async sendWechat(
|
|
414
|
+
webhookUrl: string,
|
|
415
|
+
content: string,
|
|
416
|
+
): Promise<boolean> {
|
|
417
|
+
try {
|
|
418
|
+
const payload = {
|
|
419
|
+
msgtype: 'text',
|
|
420
|
+
text: { content },
|
|
421
|
+
};
|
|
422
|
+
|
|
423
|
+
const response = await firstValueFrom(
|
|
424
|
+
this.httpService.post(webhookUrl, payload, {
|
|
425
|
+
headers: { 'Content-Type': 'application/json' },
|
|
426
|
+
timeout: 10000,
|
|
427
|
+
}),
|
|
428
|
+
);
|
|
429
|
+
|
|
430
|
+
return response.status === 200 && response.data?.errcode === 0;
|
|
431
|
+
} catch (error) {
|
|
432
|
+
this.logger.error('[Notification] Wechat failed', {
|
|
433
|
+
webhookUrl,
|
|
434
|
+
error: error instanceof Error ? error.message : String(error),
|
|
435
|
+
});
|
|
436
|
+
return false;
|
|
437
|
+
}
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
// ==================== 模板管理 ====================
|
|
441
|
+
|
|
442
|
+
/**
|
|
443
|
+
* 创建模板
|
|
444
|
+
*/
|
|
445
|
+
createTemplate(
|
|
446
|
+
template: Omit<NotificationTemplate, 'id' | 'createdAt' | 'updatedAt'>,
|
|
447
|
+
): NotificationTemplate {
|
|
448
|
+
const id = this.generateId('tpl');
|
|
449
|
+
const now = new Date();
|
|
450
|
+
|
|
451
|
+
const newTemplate: NotificationTemplate = {
|
|
452
|
+
...template,
|
|
453
|
+
id,
|
|
454
|
+
createdAt: now,
|
|
455
|
+
updatedAt: now,
|
|
456
|
+
};
|
|
457
|
+
|
|
458
|
+
this.templates.set(id, newTemplate);
|
|
459
|
+
|
|
460
|
+
this.logger.info('[Notification] Created template', {
|
|
461
|
+
id,
|
|
462
|
+
name: template.name,
|
|
463
|
+
});
|
|
464
|
+
return newTemplate;
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
/**
|
|
468
|
+
* 获取模板
|
|
469
|
+
*/
|
|
470
|
+
getTemplate(id: string): NotificationTemplate | undefined {
|
|
471
|
+
return this.templates.get(id);
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
/**
|
|
475
|
+
* 获取所有模板
|
|
476
|
+
*/
|
|
477
|
+
getTemplates(channel?: NotificationChannel): NotificationTemplate[] {
|
|
478
|
+
let templates = Array.from(this.templates.values());
|
|
479
|
+
if (channel) {
|
|
480
|
+
templates = templates.filter((t) => t.channel === channel);
|
|
481
|
+
}
|
|
482
|
+
return templates.filter((t) => t.isEnabled);
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
/**
|
|
486
|
+
* 渲染模板
|
|
487
|
+
*/
|
|
488
|
+
private renderTemplate(
|
|
489
|
+
template: string,
|
|
490
|
+
data: Record<string, unknown>,
|
|
491
|
+
): string {
|
|
492
|
+
return template.replace(/\{\{(\w+)\}\}/g, (_, key) => {
|
|
493
|
+
return String(data[key] ?? `{{${key}}}`);
|
|
494
|
+
});
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
// ==================== 偏好设置 ====================
|
|
498
|
+
|
|
499
|
+
/**
|
|
500
|
+
* 设置通知偏好
|
|
501
|
+
*/
|
|
502
|
+
setPreferences(
|
|
503
|
+
prefs: Omit<NotificationPreferences, 'id' | 'createdAt' | 'updatedAt'>,
|
|
504
|
+
): NotificationPreferences {
|
|
505
|
+
const id = this.generateId('pref');
|
|
506
|
+
const now = new Date();
|
|
507
|
+
|
|
508
|
+
const newPrefs: NotificationPreferences = {
|
|
509
|
+
...prefs,
|
|
510
|
+
id,
|
|
511
|
+
createdAt: now,
|
|
512
|
+
updatedAt: now,
|
|
513
|
+
};
|
|
514
|
+
|
|
515
|
+
// 如果已有相同用户/租户的偏好,更新之
|
|
516
|
+
for (const [existingId, existing] of this.preferences) {
|
|
517
|
+
if (
|
|
518
|
+
(prefs.userId && existing.userId === prefs.userId) ||
|
|
519
|
+
(prefs.tenantId && existing.tenantId === prefs.tenantId)
|
|
520
|
+
) {
|
|
521
|
+
newPrefs.id = existingId;
|
|
522
|
+
newPrefs.createdAt = existing.createdAt;
|
|
523
|
+
this.preferences.delete(existingId);
|
|
524
|
+
break;
|
|
525
|
+
}
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
this.preferences.set(newPrefs.id, newPrefs);
|
|
529
|
+
return newPrefs;
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
/**
|
|
533
|
+
* 获取通知偏好
|
|
534
|
+
*/
|
|
535
|
+
getPreferences(
|
|
536
|
+
userId?: string,
|
|
537
|
+
tenantId?: string,
|
|
538
|
+
): NotificationPreferences | undefined {
|
|
539
|
+
for (const prefs of this.preferences.values()) {
|
|
540
|
+
if (userId && prefs.userId === userId) return prefs;
|
|
541
|
+
if (tenantId && prefs.tenantId === tenantId) return prefs;
|
|
542
|
+
}
|
|
543
|
+
return undefined;
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
/**
|
|
547
|
+
* 检查是否在静默时段
|
|
548
|
+
*/
|
|
549
|
+
private isQuietHours(userId?: string): boolean {
|
|
550
|
+
if (!userId) return false;
|
|
551
|
+
|
|
552
|
+
const prefs = this.getPreferences(userId);
|
|
553
|
+
if (!prefs?.quietHours?.enabled) return false;
|
|
554
|
+
|
|
555
|
+
const now = new Date();
|
|
556
|
+
const currentTime = now.toTimeString().slice(0, 5); // HH:mm
|
|
557
|
+
|
|
558
|
+
const { start, end } = prefs.quietHours;
|
|
559
|
+
|
|
560
|
+
// 处理跨午夜的情况
|
|
561
|
+
if (start > end) {
|
|
562
|
+
return currentTime >= start || currentTime < end;
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
return currentTime >= start && currentTime < end;
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
// ==================== 记录和统计 ====================
|
|
569
|
+
|
|
570
|
+
/**
|
|
571
|
+
* 存储记录
|
|
572
|
+
*/
|
|
573
|
+
private storeRecord(record: NotificationRecord): void {
|
|
574
|
+
this.records.set(record.id, record);
|
|
575
|
+
|
|
576
|
+
// 限制记录数量
|
|
577
|
+
if (this.records.size > this.MAX_RECORDS) {
|
|
578
|
+
const oldestKey = this.records.keys().next().value;
|
|
579
|
+
if (oldestKey) {
|
|
580
|
+
this.records.delete(oldestKey);
|
|
581
|
+
}
|
|
582
|
+
}
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
/**
|
|
586
|
+
* 获取通知记录
|
|
587
|
+
*/
|
|
588
|
+
getRecords(filters?: {
|
|
589
|
+
channel?: NotificationChannel;
|
|
590
|
+
status?: NotificationStatus;
|
|
591
|
+
recipient?: string;
|
|
592
|
+
limit?: number;
|
|
593
|
+
}): NotificationRecord[] {
|
|
594
|
+
let records = Array.from(this.records.values());
|
|
595
|
+
|
|
596
|
+
if (filters) {
|
|
597
|
+
if (filters.channel) {
|
|
598
|
+
records = records.filter((r) => r.channel === filters.channel);
|
|
599
|
+
}
|
|
600
|
+
if (filters.status) {
|
|
601
|
+
records = records.filter((r) => r.status === filters.status);
|
|
602
|
+
}
|
|
603
|
+
if (filters.recipient) {
|
|
604
|
+
records = records.filter((r) => r.recipient === filters.recipient);
|
|
605
|
+
}
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
// 按创建时间倒序
|
|
609
|
+
records.sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime());
|
|
610
|
+
|
|
611
|
+
return records.slice(0, filters?.limit || 100);
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
/**
|
|
615
|
+
* 获取统计
|
|
616
|
+
*/
|
|
617
|
+
getStats(): NotificationStats {
|
|
618
|
+
const records = Array.from(this.records.values());
|
|
619
|
+
|
|
620
|
+
const stats: NotificationStats = {
|
|
621
|
+
total: records.length,
|
|
622
|
+
sent: records.filter((r) => r.status === 'sent').length,
|
|
623
|
+
failed: records.filter((r) => r.status === 'failed').length,
|
|
624
|
+
pending: records.filter((r) => r.status === 'pending').length,
|
|
625
|
+
byChannel: {
|
|
626
|
+
email: { sent: 0, failed: 0 },
|
|
627
|
+
webhook: { sent: 0, failed: 0 },
|
|
628
|
+
slack: { sent: 0, failed: 0 },
|
|
629
|
+
wechat: { sent: 0, failed: 0 },
|
|
630
|
+
},
|
|
631
|
+
byPriority: {
|
|
632
|
+
low: 0,
|
|
633
|
+
normal: 0,
|
|
634
|
+
high: 0,
|
|
635
|
+
critical: 0,
|
|
636
|
+
},
|
|
637
|
+
};
|
|
638
|
+
|
|
639
|
+
for (const record of records) {
|
|
640
|
+
if (record.status === 'sent') {
|
|
641
|
+
stats.byChannel[record.channel].sent++;
|
|
642
|
+
} else if (record.status === 'failed') {
|
|
643
|
+
stats.byChannel[record.channel].failed++;
|
|
644
|
+
}
|
|
645
|
+
stats.byPriority[record.priority]++;
|
|
646
|
+
}
|
|
647
|
+
|
|
648
|
+
return stats;
|
|
649
|
+
}
|
|
650
|
+
|
|
651
|
+
// ==================== 重试机制 ====================
|
|
652
|
+
|
|
653
|
+
/**
|
|
654
|
+
* 启动重试处理器
|
|
655
|
+
*/
|
|
656
|
+
private startRetryProcessor(): void {
|
|
657
|
+
setInterval(
|
|
658
|
+
() => this.processRetries(),
|
|
659
|
+
60000, // 每分钟检查一次
|
|
660
|
+
);
|
|
661
|
+
}
|
|
662
|
+
|
|
663
|
+
/**
|
|
664
|
+
* 处理重试
|
|
665
|
+
*/
|
|
666
|
+
private processRetries(): void {
|
|
667
|
+
const now = Date.now();
|
|
668
|
+
|
|
669
|
+
for (const record of this.records.values()) {
|
|
670
|
+
if (record.status !== 'failed' && record.status !== 'retrying') continue;
|
|
671
|
+
if (record.attempts >= record.maxAttempts) continue;
|
|
672
|
+
|
|
673
|
+
// 检查重试延迟
|
|
674
|
+
const delayIndex = Math.min(
|
|
675
|
+
record.attempts - 1,
|
|
676
|
+
this.RETRY_DELAYS_MS.length - 1,
|
|
677
|
+
);
|
|
678
|
+
const delay = this.RETRY_DELAYS_MS[delayIndex];
|
|
679
|
+
const lastAttempt = record.lastAttemptAt?.getTime() || 0;
|
|
680
|
+
|
|
681
|
+
if (now - lastAttempt < delay) continue;
|
|
682
|
+
|
|
683
|
+
// 重新发送
|
|
684
|
+
this.retryRecord(record);
|
|
685
|
+
}
|
|
686
|
+
}
|
|
687
|
+
|
|
688
|
+
/**
|
|
689
|
+
* 重试单条记录
|
|
690
|
+
*/
|
|
691
|
+
private async retryRecord(record: NotificationRecord): Promise<void> {
|
|
692
|
+
record.status = 'retrying';
|
|
693
|
+
record.updatedAt = new Date();
|
|
694
|
+
|
|
695
|
+
this.logger.info('[Notification] Retrying notification', {
|
|
696
|
+
recordId: record.id,
|
|
697
|
+
attempt: record.attempts + 1,
|
|
698
|
+
});
|
|
699
|
+
|
|
700
|
+
try {
|
|
701
|
+
let success = false;
|
|
702
|
+
|
|
703
|
+
switch (record.channel) {
|
|
704
|
+
case 'email':
|
|
705
|
+
success = await this.sendEmail(
|
|
706
|
+
record.recipient,
|
|
707
|
+
record.subject || '',
|
|
708
|
+
record.content,
|
|
709
|
+
);
|
|
710
|
+
break;
|
|
711
|
+
case 'webhook':
|
|
712
|
+
success = await this.sendWebhook(
|
|
713
|
+
record.recipient,
|
|
714
|
+
(record.metadata?.event as string) || 'notification',
|
|
715
|
+
(record.metadata?.data as Record<string, unknown>) || {},
|
|
716
|
+
);
|
|
717
|
+
break;
|
|
718
|
+
case 'slack':
|
|
719
|
+
success = await this.sendSlack(record.recipient, record.content);
|
|
720
|
+
break;
|
|
721
|
+
case 'wechat':
|
|
722
|
+
success = await this.sendWechat(record.recipient, record.content);
|
|
723
|
+
break;
|
|
724
|
+
}
|
|
725
|
+
|
|
726
|
+
if (success) {
|
|
727
|
+
record.status = 'sent';
|
|
728
|
+
record.sentAt = new Date();
|
|
729
|
+
} else {
|
|
730
|
+
record.status = 'failed';
|
|
731
|
+
record.failedAt = new Date();
|
|
732
|
+
}
|
|
733
|
+
} catch (error) {
|
|
734
|
+
record.status = 'failed';
|
|
735
|
+
record.failedAt = new Date();
|
|
736
|
+
record.errorMessage =
|
|
737
|
+
error instanceof Error ? error.message : String(error);
|
|
738
|
+
}
|
|
739
|
+
|
|
740
|
+
record.attempts++;
|
|
741
|
+
record.lastAttemptAt = new Date();
|
|
742
|
+
record.updatedAt = new Date();
|
|
743
|
+
|
|
744
|
+
this.records.set(record.id, record);
|
|
745
|
+
}
|
|
746
|
+
|
|
747
|
+
// ==================== 工具方法 ====================
|
|
748
|
+
|
|
749
|
+
/**
|
|
750
|
+
* 生成 ID
|
|
751
|
+
*/
|
|
752
|
+
private generateId(prefix: string): string {
|
|
753
|
+
return `${prefix}_${Date.now()}_${Math.random().toString(36).substring(2, 9)}`;
|
|
754
|
+
}
|
|
755
|
+
|
|
756
|
+
/**
|
|
757
|
+
* 初始化默认模板
|
|
758
|
+
*/
|
|
759
|
+
private initializeDefaultTemplates(): void {
|
|
760
|
+
// 预算预警模板
|
|
761
|
+
this.createTemplate({
|
|
762
|
+
name: 'budget-warning',
|
|
763
|
+
channel: 'email',
|
|
764
|
+
subject: 'Budget Warning: {{budgetName}}',
|
|
765
|
+
content: `Your budget "{{budgetName}}" has reached {{percentage}}% of its limit.\n\nUsed: {{used}} {{currency}}\nLimit: {{limit}} {{currency}}\n\nPlease review your usage.`,
|
|
766
|
+
variables: ['budgetName', 'percentage', 'used', 'limit', 'currency'],
|
|
767
|
+
isEnabled: true,
|
|
768
|
+
});
|
|
769
|
+
|
|
770
|
+
// 预算超限模板
|
|
771
|
+
this.createTemplate({
|
|
772
|
+
name: 'budget-exceeded',
|
|
773
|
+
channel: 'email',
|
|
774
|
+
subject: 'Budget Exceeded: {{budgetName}}',
|
|
775
|
+
content: `Your budget "{{budgetName}}" has been exceeded.\n\nUsed: {{used}} {{currency}}\nLimit: {{limit}} {{currency}}\n\nFurther requests may be blocked until the budget is reset.`,
|
|
776
|
+
variables: ['budgetName', 'used', 'limit', 'currency'],
|
|
777
|
+
isEnabled: true,
|
|
778
|
+
});
|
|
779
|
+
|
|
780
|
+
// Webhook 通知模板
|
|
781
|
+
this.createTemplate({
|
|
782
|
+
name: 'webhook-notification',
|
|
783
|
+
channel: 'webhook',
|
|
784
|
+
content: '{{message}}',
|
|
785
|
+
variables: ['message'],
|
|
786
|
+
isEnabled: true,
|
|
787
|
+
});
|
|
788
|
+
|
|
789
|
+
this.logger.info('[Notification] Default templates initialized');
|
|
790
|
+
}
|
|
791
|
+
}
|