From 62e53c3cdc7d8b712dcd49ea7b039042614deaf3 Mon Sep 17 00:00:00 2001 From: Baobhan Sith <80159437+Heavrnl@users.noreply.github.com> Date: Sat, 26 Apr 2025 19:39:03 +0800 Subject: [PATCH] update --- packages/backend/src/i18n.ts | 72 ++--- packages/backend/src/index.ts | 5 + .../{en-US/notifications.json => en-US.json} | 21 +- .../{ja-JP/notifications.json => ja-JP.json} | 19 +- .../{zh-CN/notifications.json => zh-CN.json} | 30 +- .../notifications/notification.controller.ts | 130 +++++--- .../backend/src/services/event.service.ts | 94 ++++++ .../notification.dispatcher.service.ts | 79 +++++ .../notification.processor.service.ts | 278 ++++++++++++++++++ .../services/senders/email.sender.service.ts | 98 ++++++ .../senders/telegram.sender.service.ts | 53 ++++ .../senders/webhook.sender.service.ts | 107 +++++++ .../src/stores/notifications.store.ts | 4 +- 13 files changed, 846 insertions(+), 144 deletions(-) rename packages/backend/src/locales/{en-US/notifications.json => en-US.json} (73%) rename packages/backend/src/locales/{ja-JP/notifications.json => ja-JP.json} (70%) rename packages/backend/src/locales/{zh-CN/notifications.json => zh-CN.json} (51%) create mode 100644 packages/backend/src/services/event.service.ts create mode 100644 packages/backend/src/services/notification.dispatcher.service.ts create mode 100644 packages/backend/src/services/notification.processor.service.ts create mode 100644 packages/backend/src/services/senders/email.sender.service.ts create mode 100644 packages/backend/src/services/senders/telegram.sender.service.ts create mode 100644 packages/backend/src/services/senders/webhook.sender.service.ts diff --git a/packages/backend/src/i18n.ts b/packages/backend/src/i18n.ts index 9c067d9..25d5cf4 100644 --- a/packages/backend/src/i18n.ts +++ b/packages/backend/src/i18n.ts @@ -7,51 +7,55 @@ import fs from 'fs'; const localesDir = path.join(__dirname, 'locales'); let dynamicSupportedLngs: string[] = []; try { - // 同步读取 locales 目录下的所有条目 const entries = fs.readdirSync(localesDir, { withFileTypes: true }); - // 过滤出目录,并将目录名作为支持的语言代码 + // Filter for .json files directly, assuming filenames are language codes (e.g., en-US.json) dynamicSupportedLngs = entries - .filter(dirent => dirent.isDirectory()) - .map(dirent => dirent.name); + .filter(dirent => dirent.isFile() && dirent.name.endsWith('.json')) + .map(dirent => dirent.name.replace('.json', '')); // Extract lang code from filename console.log('[i18next] Dynamically detected languages:', dynamicSupportedLngs); } catch (err) { console.error('[i18next] Error reading locales directory:', err); - // 如果读取目录失败,可以回退到默认值或抛出错误 - dynamicSupportedLngs = ['en']; // 至少包含默认语言作为回退 + dynamicSupportedLngs = ['en-US']; // Fallback } -// 确保默认语言在支持列表中,如果目录扫描失败则添加 -export const defaultLng = 'en-US'; // 更新为 en-US +export const defaultLng = 'en-US'; if (!dynamicSupportedLngs.includes(defaultLng)) { dynamicSupportedLngs.push(defaultLng); - console.warn(`[i18next] Default language '${defaultLng}' not found in detected directories, adding it to supported list.`); + console.warn(`[i18next] Default language '${defaultLng}' not found in detected files, adding it to supported list.`); } -export const supportedLngs = dynamicSupportedLngs; // 导出动态获取的列表 +export const supportedLngs = dynamicSupportedLngs; // --- 结束动态确定 --- -i18next - .use(Backend) - .init({ - debug: process.env.NODE_ENV === 'development', // Enable debug logging in dev - supportedLngs: supportedLngs, // 使用动态获取的列表 - fallbackLng: defaultLng, - // lng: defaultLng, // Remove explicit lng setting here, let it be determined later or by detector - preload: supportedLngs, // 使用动态获取的列表进行预加载 - ns: ['notifications'], // 命名空间,用于组织翻译 - defaultNS: 'notifications', - backend: { - // path where resources get loaded from - loadPath: path.join(localesDir, '{{lng}}/{{ns}}.json'), // 直接使用 localesDir - }, - interpolation: { - escapeValue: false, // Not needed for react apps - }, - }, (err, t) => { // Add init callback - if (err) { - return console.error('[i18next] Error during initialization:', err); - } - console.log('[i18next] Initialization complete. Loaded languages:', Object.keys(i18next.store.data)); - }); +let i18nInitialized = false; +// Create a promise that resolves when i18next is initialized +const i18nInitializationPromise = new Promise((resolve, reject) => { + i18next + .use(Backend) + .init({ + debug: process.env.NODE_ENV === 'development', + supportedLngs: supportedLngs, + fallbackLng: defaultLng, + preload: supportedLngs, + // ns and defaultNS removed as translations are now in root language files (e.g., en-US.json) + backend: { + loadPath: path.join(localesDir, '{{lng}}.json'), // Load root JSON files directly + }, + interpolation: { + escapeValue: false, + }, + }, (err, t) => { // Init callback + if (err) { + console.error('[i18next] Error during initialization:', err); + i18nInitialized = false; // Mark as not initialized on error + return reject(err); // Reject the promise on error + } + console.log('[i18next] Initialization complete. Loaded languages:', Object.keys(i18next.store.data || {})); // Safe access to store.data + i18nInitialized = true; // Mark as initialized + resolve(); // Resolve the promise on success + }); +}); -export default i18next; +// Export the promise and a function to check status (optional) +export { i18nInitializationPromise, i18nInitialized }; +export default i18next; // Export the instance as well diff --git a/packages/backend/src/index.ts b/packages/backend/src/index.ts index 258bdfb..844d91b 100644 --- a/packages/backend/src/index.ts +++ b/packages/backend/src/index.ts @@ -23,6 +23,11 @@ import appearanceRoutes from './appearance/appearance.routes'; import { initializeWebSocket } from './websocket'; import { ipWhitelistMiddleware } from './auth/ipWhitelist.middleware'; +// --- 初始化通知系统 (导入即初始化单例) --- +import './services/event.service'; // 确保事件服务被加载 +import './services/notification.processor.service'; // 确保处理器被加载并监听事件 +import './services/notification.dispatcher.service'; // 确保分发器被加载并监听处理器事件 +// --- 结束通知系统初始化 --- // --- 环境变量和密钥初始化 --- const initializeEnvironment = async () => { const rootEnvPath = path.resolve(__dirname, '../../.env'); // 指向项目根目录的 .env diff --git a/packages/backend/src/locales/en-US/notifications.json b/packages/backend/src/locales/en-US.json similarity index 73% rename from packages/backend/src/locales/en-US/notifications.json rename to packages/backend/src/locales/en-US.json index 0b53627..7aa17d1 100644 --- a/packages/backend/src/locales/en-US/notifications.json +++ b/packages/backend/src/locales/en-US.json @@ -2,14 +2,14 @@ "testNotification": { "subject": "Nexus Terminal Test Notification ({event})", "email": { - "body": "This is a test email from Nexus Terminal for event '{{event}}'.\n\nIf you received this, your SMTP configuration is working.\n\nTimestamp: {{timestamp}}", - "bodyHtml": "

This is a test email from Nexus Terminal for event '{{event}}'.

If you received this, your SMTP configuration is working.

Timestamp: {{timestamp}}

" + "body": "This is a test email from Nexus Terminal for event '{event}'.\n\nIf you received this, your SMTP configuration is working.\n\nTimestamp: {timestamp}", + "bodyHtml": "

This is a test email from Nexus Terminal for event '{event}'.

If you received this, your SMTP configuration is working.

Timestamp: {timestamp}

" }, "webhook": { - "detailsMessage": "This is a test notification from Nexus Terminal (Webhook - i18n) for event '{{event}}'." + "detailsMessage": "This is a test notification from Nexus Terminal (Webhook - i18n) for event '{event}'." }, "telegram": { - "detailsMessage": "This is a test notification from Nexus Terminal (Telegram - i18n) for event '{{event}}'.", + "detailsMessage": "This is a test notification from Nexus Terminal (Telegram - i18n) for event '{event}'.", "bodyTemplate": "*Nexus Terminal Test Notification*\nEvent: `{event}`\nTimestamp: {timestamp}\nDetails:\n```\n{details}\n```" } }, @@ -47,18 +47,5 @@ "SERVER_ERROR": "Server Error", "DATABASE_MIGRATION": "Database Migration", "ADMIN_SETUP_COMPLETE": "Initial Admin Setup Completed" - - }, - "eventBody": { - - "SETTINGS_UPDATED": "Event: {{event}}\nTimestamp: {{timestamp}}\nDetails:\n{{details}}" - }, - "connection": { - "testSuccess": "Connection test successful for '{{name}}'!", - "testFailed": "Connection test failed for '{{name}}': {{error}}" - }, - "settings": { - "ipWhitelistUpdated": "IP Whitelist updated successfully.", - "updated": "Settings updated successfully." } } \ No newline at end of file diff --git a/packages/backend/src/locales/ja-JP/notifications.json b/packages/backend/src/locales/ja-JP.json similarity index 70% rename from packages/backend/src/locales/ja-JP/notifications.json rename to packages/backend/src/locales/ja-JP.json index 1055d5f..c54e868 100644 --- a/packages/backend/src/locales/ja-JP/notifications.json +++ b/packages/backend/src/locales/ja-JP.json @@ -2,14 +2,14 @@ "testNotification": { "subject": "星枢ターミナル テスト通知 ({event})", "email": { - "body": "これは、星枢ターミナルからのイベント'{{event}}'に関するテストメールです。\n\nこのメールを受信した場合、SMTP 設定は正常に機能しています。\n\nタイムスタンプ: {{timestamp}}", - "bodyHtml": "

これは、星枢ターミナルからのイベント'{{event}}'に関するテストメールです。

このメールを受信した場合、SMTP 設定は正常に機能しています。

タイムスタンプ: {{timestamp}}

" + "body": "これは、星枢ターミナルからのイベント'{event}'に関するテストメールです。\n\nこのメールを受信した場合、SMTP 設定は正常に機能しています。\n\nタイムスタンプ: {timestamp}", + "bodyHtml": "

これは、星枢ターミナルからのイベント'{event}'に関するテストメールです。

このメールを受信した場合、SMTP 設定は正常に機能しています。

タイムスタンプ: {timestamp}

" }, "webhook": { - "detailsMessage": "これは星枢ターミナルからのテスト通知 (Webhook - i18n) です。 イベント:'{{event}}'。" + "detailsMessage": "これは星枢ターミナルからのテスト通知 (Webhook - i18n) です。 イベント:'{event}'。" }, "telegram": { - "detailsMessage": "これは星枢ターミナルからのテスト通知 (Telegram - i18n) です。 イベント:'{{event}}'。", + "detailsMessage": "これは星枢ターミナルからのテスト通知 (Telegram - i18n) です。 イベント:'{event}'。", "bodyTemplate": "*星枢ターミナル テスト通知*\nイベント: `{event}`\nタイムスタンプ: {timestamp}\n詳細:\n```\n{details}\n```" } }, @@ -47,16 +47,5 @@ "SERVER_ERROR": "サーバーエラー", "DATABASE_MIGRATION": "データベース移行", "ADMIN_SETUP_COMPLETE": "初期管理者設定完了" - }, - "eventBody": { - "SETTINGS_UPDATED": "イベント: {{event}}\nタイムスタンプ: {{timestamp}}\n詳細:\n{{details}}" - }, - "connection": { - "testSuccess": "接続 '{{name}}' のテストに成功しました!", - "testFailed": "接続 '{{name}}' のテストに失敗しました: {{error}}" - }, - "settings": { - "ipWhitelistUpdated": "IP ホワイトリストを更新しました。", - "updated": "設定を更新しました。" } } \ No newline at end of file diff --git a/packages/backend/src/locales/zh-CN/notifications.json b/packages/backend/src/locales/zh-CN.json similarity index 51% rename from packages/backend/src/locales/zh-CN/notifications.json rename to packages/backend/src/locales/zh-CN.json index 3ffc2d6..2057a8f 100644 --- a/packages/backend/src/locales/zh-CN/notifications.json +++ b/packages/backend/src/locales/zh-CN.json @@ -1,18 +1,4 @@ { - "testNotification": { - "subject": "星枢终端测试通知 ({event})", - "email": { - "body": "这是一封来自星枢终端关于事件 '{{event}}' 的测试邮件。\n\n如果您收到此邮件,表示您的 SMTP 配置工作正常。\n\n时间戳: {{timestamp}}", - "bodyHtml": "

这是一封来自 星枢终端 关于事件 '{{event}}' 的测试邮件。

如果您收到此邮件,表示您的 SMTP 配置工作正常。

时间戳: {{timestamp}}

" - }, - "webhook": { - "detailsMessage": "这是一条来自星枢终端的测试通知 (Webhook - i18n),事件:'{{event}}'。" - }, - "telegram": { - "detailsMessage": "这是一条来自星枢终端的测试通知 (Telegram - i18n),事件:'{{event}}'。", - "bodyTemplate": "*星枢终端测试通知*\n事件: `{event}`\n时间戳: {timestamp}\n详情:\n```\n{details}\n```" - } - }, "event": { "LOGIN_SUCCESS": "登录成功", "LOGIN_FAILURE": "登录失败", @@ -46,19 +32,7 @@ "SERVER_STARTED": "服务器已启动", "SERVER_ERROR": "服务器错误", "DATABASE_MIGRATION": "数据库迁移", - "ADMIN_SETUP_COMPLETE": "初始管理员设置完成" - - }, - "eventBody": { - - "SETTINGS_UPDATED": "事件: {{event}}\n时间戳: {{timestamp}}\n详情:\n{{details}}" - }, - "connection": { - "testSuccess": "连接 '{{name}}' 测试成功!", - "testFailed": "连接 '{{name}}' 测试失败: {{error}}" - }, - "settings": { - "ipWhitelistUpdated": "IP 白名单更新成功。", - "updated": "设置更新成功。" + "ADMIN_SETUP_COMPLETE": "初始管理员设置完成", + "testNotification": "测试通知" } } \ No newline at end of file diff --git a/packages/backend/src/notifications/notification.controller.ts b/packages/backend/src/notifications/notification.controller.ts index d71c9f1..356e318 100644 --- a/packages/backend/src/notifications/notification.controller.ts +++ b/packages/backend/src/notifications/notification.controller.ts @@ -1,21 +1,30 @@ import { Request, Response } from 'express'; -import { NotificationService } from '../services/notification.service'; -import { NotificationSetting } from '../types/notification.types'; -import { AuditLogService } from '../services/audit.service'; +import { NotificationSettingsRepository } from '../repositories/notification.repository'; // Use repository +import { NotificationSetting, NotificationChannelType, NotificationChannelConfig, WebhookConfig, EmailConfig, TelegramConfig, NotificationEvent } from '../types/notification.types'; +import { AuditLogService } from '../services/audit.service'; // Keep for now if other parts use it +import { AppEventType, default as eventService } from '../services/event.service'; // Import event service -const auditLogService = new AuditLogService(); +// Remove sender imports as they are no longer called directly for testing +// import telegramSenderService from '../services/senders/telegram.sender.service'; +// import emailSenderService from '../services/senders/email.sender.service'; +// import webhookSenderService from '../services/senders/webhook.sender.service'; +// import { ProcessedNotification } from '../services/notification.processor.service'; // Not needed here + +// Removed escapeTelegramMarkdownV2 helper function + +const auditLogService = new AuditLogService(); // Keep for now if other parts use it, but prefer eventService export class NotificationController { - private notificationService: NotificationService; + private repository: NotificationSettingsRepository; // Use repository constructor() { - this.notificationService = new NotificationService(); + this.repository = new NotificationSettingsRepository(); // Instantiate repository } // GET /api/v1/notifications getAll = async (req: Request, res: Response): Promise => { try { - const settings = await this.notificationService.getAllSettings(); + const settings = await this.repository.getAll(); // Use repository res.status(200).json(settings); } catch (error: any) { res.status(500).json({ message: '获取通知设置失败', error: error.message }); @@ -32,11 +41,14 @@ export class NotificationController { } try { - const newSettingId = await this.notificationService.createSetting(settingData); - const newSetting = await this.notificationService.getSettingById(newSettingId); - // 记录审计日志 + const newSettingId = await this.repository.create(settingData); // Use repository + const newSetting = await this.repository.getById(newSettingId); + // 记录审计日志 (Use event service) if (newSetting) { - auditLogService.logAction('NOTIFICATION_SETTING_CREATED', { settingId: newSetting.id, name: newSetting.name, type: newSetting.channel_type }); + eventService.emitEvent(AppEventType.NotificationSettingCreated, { + userId: (req.session as any).userId, // Assuming userId is in session + details: { settingId: newSetting.id, name: newSetting.name, type: newSetting.channel_type } + }); } res.status(201).json(newSetting); } catch (error: any) { @@ -59,11 +71,14 @@ export class NotificationController { } try { - const success = await this.notificationService.updateSetting(id, settingData); + const success = await this.repository.update(id, settingData); // Use repository if (success) { - const updatedSetting = await this.notificationService.getSettingById(id); - // 记录审计日志 - auditLogService.logAction('NOTIFICATION_SETTING_UPDATED', { settingId: id, updatedFields: Object.keys(settingData) }); + const updatedSetting = await this.repository.getById(id); + // 记录审计日志 (Use event service) + eventService.emitEvent(AppEventType.NotificationSettingUpdated, { + userId: (req.session as any).userId, + details: { settingId: id, updatedFields: Object.keys(settingData) } + }); res.status(200).json(updatedSetting); } else { res.status(404).json({ message: `未找到 ID 为 ${id} 的通知设置` }); @@ -83,81 +98,100 @@ export class NotificationController { } try { - const success = await this.notificationService.deleteSetting(id); + const settingToDelete = await this.repository.getById(id); // Get details before deleting for audit log + if (!settingToDelete) { + res.status(404).json({ message: `未找到 ID 为 ${id} 的通知设置` }); + return; + } + const success = await this.repository.delete(id); // Use repository if (success) { - // 记录审计日志 - auditLogService.logAction('NOTIFICATION_SETTING_DELETED', { settingId: id }); + // 记录审计日志 (Use event service) + eventService.emitEvent(AppEventType.NotificationSettingDeleted, { + userId: (req.session as any).userId, + details: { settingId: id, name: settingToDelete.name, type: settingToDelete.channel_type } // Include name/type in audit + }); res.status(204).send(); // No Content } else { - res.status(404).json({ message: `未找到 ID 为 ${id} 的通知设置` }); + // Should not happen if getById succeeded, but handle defensively + res.status(404).json({ message: `删除 ID 为 ${id} 的通知设置失败,可能已被删除` }); } } catch (error: any) { res.status(500).json({ message: '删除通知设置失败', error: error.message }); } }; + // --- Refactored Test Endpoints --- + + // Removed executeTestSend method as testing now goes through the event system + // POST /api/v1/notifications/:id/test + // Tests an existing, saved setting configuration by triggering a test event testSetting = async (req: Request, res: Response): Promise => { const id = parseInt(req.params.id, 10); - const { config } = req.body; if (isNaN(id)) { res.status(400).json({ message: '无效的通知设置 ID' }); return; } - if (!config) { - res.status(400).json({ message: '缺少用于测试的配置信息' }); - return; - } try { - const originalSetting = await this.notificationService.getSettingById(id); - if (!originalSetting) { + const settingToTest = await this.repository.getById(id); + if (!settingToTest) { res.status(404).json({ message: `未找到 ID 为 ${id} 的通知设置` }); - return; + return; } - const result = await this.notificationService.testSetting(originalSetting.channel_type, config); + // Trigger the standard test event, passing the config to be used by the processor + eventService.emitEvent(AppEventType.TestNotification, { + userId: (req.session as any).userId, // Optional: associate test with user + details: { + message: `为设置 ID ${id} (${settingToTest.name}) 触发的测试`, + testTargetConfig: settingToTest.config, // Pass the config to use + testTargetChannelType: settingToTest.channel_type // Pass the channel type + } + }); + + // Respond immediately confirming the event was triggered + res.status(200).json({ message: '测试通知事件已触发。请检查对应渠道的接收情况。' }); - if (result.success) { - // 记录审计日志 (可选,根据需要决定是否记录测试操作) - - res.status(200).json({ message: result.message }); - } else { - // 记录审计日志 (可选) - - res.status(500).json({ message: result.message }); - } } catch (error: any) { - res.status(500).json({ message: '测试通知设置时发生内部错误', error: error.message }); + console.error(`[NotificationController] Error triggering test for setting ${id}:`, error); + res.status(500).json({ message: '触发测试通知时发生内部错误', error: error.message }); } }; // POST /api/v1/notifications/test-unsaved + // Tests configuration data provided in the request body by triggering a test event testUnsavedSetting = async (req: Request, res: Response): Promise => { - const { channel_type, config } = req.body; + const { channel_type, config } = req.body as { channel_type: NotificationChannelType, config: NotificationChannelConfig }; if (!channel_type || !config) { res.status(400).json({ message: '缺少必要的测试信息 (channel_type, config)' }); return; } - // Basic validation for channel type if (!['webhook', 'email', 'telegram'].includes(channel_type)) { res.status(400).json({ message: '无效的渠道类型' }); return; } try { - const result = await this.notificationService.testSetting(channel_type, config); + // Trigger the standard test event, passing the unsaved config to be used by the processor + eventService.emitEvent(AppEventType.TestNotification, { + userId: (req.session as any).userId, + details: { + message: `为未保存的 ${channel_type} 配置触发的测试`, + testTargetConfig: config, // Pass the unsaved config to use + testTargetChannelType: channel_type // Pass the channel type + } + }); + + // Respond immediately confirming the event was triggered + res.status(200).json({ message: '测试通知事件已触发。请检查对应渠道的接收情况。' }); - if (result.success) { - res.status(200).json({ message: result.message }); - } else { - res.status(500).json({ message: result.message }); - } } catch (error: any) { - res.status(500).json({ message: '测试通知设置时发生内部错误', error: error.message }); + console.error(`[NotificationController] Error triggering test for unsaved ${channel_type}:`, error); + res.status(500).json({ message: '触发测试通知时发生内部错误', error: error.message }); } }; -} +} // End of class NotificationController diff --git a/packages/backend/src/services/event.service.ts b/packages/backend/src/services/event.service.ts new file mode 100644 index 0000000..da47563 --- /dev/null +++ b/packages/backend/src/services/event.service.ts @@ -0,0 +1,94 @@ +import { EventEmitter } from 'events'; + +// 定义支持的事件类型 +// 这里可以根据 packages/backend/src/locales/zh-CN.json 中的 event 部分来扩展 +export enum AppEventType { + TestNotification = 'testNotification', // 用于测试 + LoginSuccess = 'LOGIN_SUCCESS', + LoginFailure = 'LOGIN_FAILURE', + Logout = 'LOGOUT', + PasswordChanged = 'PASSWORD_CHANGED', + TwoFactorEnabled = '2FA_ENABLED', + TwoFactorDisabled = '2FA_DISABLED', + PasskeyRegistered = 'PASSKEY_REGISTERED', + PasskeyDeleted = 'PASSKEY_DELETED', + ConnectionCreated = 'CONNECTION_CREATED', + ConnectionUpdated = 'CONNECTION_UPDATED', + ConnectionDeleted = 'CONNECTION_DELETED', + ConnectionTested = 'CONNECTION_TESTED', + ConnectionsImported = 'CONNECTIONS_IMPORTED', + ConnectionsExported = 'CONNECTIONS_EXPORTED', + ProxyCreated = 'PROXY_CREATED', + ProxyUpdated = 'PROXY_UPDATED', + ProxyDeleted = 'PROXY_DELETED', + TagCreated = 'TAG_CREATED', + TagUpdated = 'TAG_UPDATED', + TagDeleted = 'TAG_DELETED', + SettingsUpdated = 'SETTINGS_UPDATED', + IpWhitelistUpdated = 'IP_WHITELIST_UPDATED', + NotificationSettingCreated = 'NOTIFICATION_SETTING_CREATED', + NotificationSettingUpdated = 'NOTIFICATION_SETTING_UPDATED', + NotificationSettingDeleted = 'NOTIFICATION_SETTING_DELETED', + SftpAction = 'SFTP_ACTION', + SshConnectSuccess = 'SSH_CONNECT_SUCCESS', + SshConnectFailure = 'SSH_CONNECT_FAILURE', + SshShellFailure = 'SSH_SHELL_FAILURE', + ServerStarted = 'SERVER_STARTED', + ServerError = 'SERVER_ERROR', + DatabaseMigration = 'DATABASE_MIGRATION', + AdminSetupComplete = 'ADMIN_SETUP_COMPLETE', + // 可以根据需要添加更多事件类型 +} + +// 定义事件负载的通用接口,可以根据具体事件扩展 +export interface AppEventPayload { + userId?: number; // 事件关联的用户 ID(如果适用) + timestamp: Date; // 事件发生的时间戳 + details?: Record; // 事件相关的具体数据 + [key: string]: any; // 允许其他任意属性 +} + +class EventService extends EventEmitter { + constructor() { + super(); + // 增加监听器数量限制,防止潜在的内存泄漏警告 + this.setMaxListeners(50); + } + + /** + * 触发一个应用事件 + * @param eventType 事件类型 + * @param payload 事件负载数据 + */ + emitEvent(eventType: AppEventType, payload: Omit) { + const fullPayload: AppEventPayload = { + ...payload, + timestamp: new Date(), + }; + this.emit(eventType, fullPayload); + console.log(`Event emitted: ${eventType}`, fullPayload); // 日志记录,方便调试 + } + + /** + * 注册事件监听器 + * @param eventType 事件类型 + * @param listener 监听函数 + */ + onEvent(eventType: AppEventType, listener: (payload: AppEventPayload) => void) { + this.on(eventType, listener); + } + + /** + * 移除事件监听器 + * @param eventType 事件类型 + * @param listener 监听函数 + */ + offEvent(eventType: AppEventType, listener: (payload: AppEventPayload) => void) { + this.off(eventType, listener); + } +} + +// 创建单例 +const eventService = new EventService(); + +export default eventService; \ No newline at end of file diff --git a/packages/backend/src/services/notification.dispatcher.service.ts b/packages/backend/src/services/notification.dispatcher.service.ts new file mode 100644 index 0000000..84522eb --- /dev/null +++ b/packages/backend/src/services/notification.dispatcher.service.ts @@ -0,0 +1,79 @@ +import notificationProcessorService, { ProcessedNotification } from './notification.processor.service'; // 导入导出的接口 +import { NotificationChannelType, NotificationChannelConfig } from '../types/notification.types'; + +// 1. 定义通知发送器接口 +export interface INotificationSender { + send(notification: ProcessedNotification): Promise; +} + +// 导入具体的发送器实现 +import telegramSenderService from './senders/telegram.sender.service'; +import emailSenderService from './senders/email.sender.service'; +import webhookSenderService from './senders/webhook.sender.service'; + + +class NotificationDispatcherService { + // 使用 Map 来存储不同渠道类型的发送器实例 + private senders: Map; + + constructor() { + this.senders = new Map(); + // 注册具体的发送器实例 + this.registerSender('telegram', telegramSenderService); + this.registerSender('email', emailSenderService); + this.registerSender('webhook', webhookSenderService); + + this.listenForNotifications(); + } + + /** + * 注册一个通知发送器实例 + * @param channelType 渠道类型 + * @param sender 发送器实例 + */ + registerSender(channelType: NotificationChannelType, sender: INotificationSender) { + if (this.senders.has(channelType)) { + console.warn(`[NotificationDispatcher] Sender for channel type '${channelType}' is already registered. Overwriting.`); + } + this.senders.set(channelType, sender); + console.log(`[NotificationDispatcher] Registered sender for channel type '${channelType}'.`); + } + + private listenForNotifications() { + notificationProcessorService.on('sendNotification', (processedNotification: ProcessedNotification) => { + // 使用 setImmediate 避免阻塞 + setImmediate(() => { + this.dispatchNotification(processedNotification).catch(error => { + console.error(`[NotificationDispatcher] Error dispatching notification for channel ${processedNotification.channelType}:`, error); + }); + }); + }); + console.log('[NotificationDispatcher] Listening for processed notifications.'); + } + + private async dispatchNotification(notification: ProcessedNotification) { + const sender = this.senders.get(notification.channelType); + + if (!sender) { + console.warn(`[NotificationDispatcher] No sender registered for channel type: ${notification.channelType}. Skipping notification.`); + return; + } + + console.log(`[NotificationDispatcher] Dispatching notification via ${notification.channelType}`); + try { + await sender.send(notification); + console.log(`[NotificationDispatcher] Successfully sent notification via ${notification.channelType}`); + } catch (error) { + console.error(`[NotificationDispatcher] Failed to send notification via ${notification.channelType}:`, error); + // 这里可以添加失败重试或记录失败状态的逻辑 + } + } +} + +// 创建单例并导出 +const notificationDispatcherService = new NotificationDispatcherService(); + +// 导出接口,以便其他发送器可以实现它 +// (或者将接口移到 types 文件中) + +export default notificationDispatcherService; \ No newline at end of file diff --git a/packages/backend/src/services/notification.processor.service.ts b/packages/backend/src/services/notification.processor.service.ts new file mode 100644 index 0000000..df81b2a --- /dev/null +++ b/packages/backend/src/services/notification.processor.service.ts @@ -0,0 +1,278 @@ +import eventService, { AppEventType, AppEventPayload } from './event.service'; +import { NotificationSettingsRepository } from '../repositories/notification.repository'; +import { NotificationSetting, NotificationEvent, NotificationChannelType, WebhookConfig, EmailConfig, TelegramConfig, NotificationChannelConfig } from '../types/notification.types'; +import i18next, { i18nInitializationPromise } from '../i18n'; // Import the promise +import { EventEmitter } from 'events'; + +// 定义处理后的通知数据结构 +// Exporting for use in dispatcher +export interface ProcessedNotification { + channelType: NotificationChannelType; + config: NotificationChannelConfig; // 包含发送所需的配置,如 URL, Token, SMTP 等 + subject?: string; // 主要用于 Email + body: string; // 格式化后的通知内容主体 + rawPayload: AppEventPayload; // 原始事件负载,可能需要传递给发送器 +} + + +class NotificationProcessorService extends EventEmitter { + private repository: NotificationSettingsRepository; + private isInitialized = false; // Flag to track initialization + + constructor() { + super(); + this.repository = new NotificationSettingsRepository(); + this.initialize(); // Call async initialization method + // Increase listener limit + this.setMaxListeners(50); + } + + // Async initialization method + private async initialize(): Promise { + try { + console.log('[NotificationProcessor] Waiting for i18n initialization...'); + await i18nInitializationPromise; // Wait for i18n to load + console.log('[NotificationProcessor] i18n initialized. Registering event listeners...'); + this.registerEventListeners(); + this.isInitialized = true; + console.log('[NotificationProcessor] Initialization complete.'); + } catch (error) { + console.error('[NotificationProcessor] Failed to initialize due to i18n error:', error); + // Handle initialization failure, maybe retry or log critical error + } + } + + + private registerEventListeners() { + if (this.isInitialized) { + console.warn('[NotificationProcessor] Attempted to register listeners multiple times.'); + return; // Prevent double registration + } + // 监听所有 AppEventType 事件 + Object.values(AppEventType).forEach(eventType => { + // Special handling for TestNotification is done inside processEvent handlers below + if (eventType !== AppEventType.TestNotification) { + eventService.onEvent(eventType, (payload) => { + // 使用 setImmediate 或 process.nextTick 避免阻塞事件循环 + setImmediate(() => { + this.processStandardEvent(eventType, payload).catch(error => { + console.error(`[NotificationProcessor] Error processing event ${eventType}:`, error); + }); + }); + }); + } + }); + // Separate listener specifically for TestNotification + eventService.onEvent(AppEventType.TestNotification, (payload) => { + setImmediate(() => { + this.processTestEvent(payload).catch(error => { + console.error(`[NotificationProcessor] Error processing test event:`, error); + }); + }); + }); + console.log('[NotificationProcessor] Registered listeners.'); + } + + // Handles standard events by fetching settings from DB + private async processStandardEvent(eventType: AppEventType, payload: AppEventPayload) { + if (!this.isInitialized) { + console.warn(`[NotificationProcessor] Received event ${eventType} before initialization. Skipping.`); + return; + } + console.log(`[NotificationProcessor] Received standard event: ${eventType}`, payload); + const eventKey = eventType as NotificationEvent; // 类型转换,假设 AppEventType 和 NotificationEvent 对应 + + try { + const applicableSettings = await this.repository.getEnabledByEvent(eventKey); + console.log(`[NotificationProcessor] Found ${applicableSettings.length} applicable settings for event ${eventKey}`); + + if (applicableSettings.length === 0) { + return; // 没有配置需要处理 + } + + // TODO: 获取用户语言偏好,目前硬编码为 'zh-CN' + const userLang = 'zh-CN'; // 后续应从用户设置或请求中获取 + + // 1. 翻译事件名称 + const translatedEvent = i18next.t(`event.${eventKey}`, { lng: userLang, defaultValue: eventKey }); +// --- DEBUG LOG --- + console.log(`[NotificationProcessor] Translating event key '${eventKey}' for lang '${userLang}'. Result: '${translatedEvent}'`); + // --- END DEBUG LOG --- + // --- DEBUG LOG --- + console.log(`[NotificationProcessor] Translating event key '${eventKey}' for lang '${userLang}'. Result: '${translatedEvent}'`); + // --- END DEBUG LOG --- + + + for (const setting of applicableSettings) { + this.processSingleSetting(setting, eventType, payload, translatedEvent, userLang); + } + } catch (error) { + console.error(`[NotificationProcessor] Failed to fetch settings for event ${eventKey}:`, error); + } + } + + // Handles the specific TestNotification event using config from payload + private async processTestEvent(payload: AppEventPayload) { + if (!this.isInitialized) { + console.warn(`[NotificationProcessor] Received test event before initialization. Skipping.`); + return; + } + console.log(`[NotificationProcessor] Received test event`, payload); + const { testTargetConfig, testTargetChannelType } = payload.details || {}; + + if (!testTargetConfig || !testTargetChannelType) { + console.error('[NotificationProcessor] Test event payload missing testTargetConfig or testTargetChannelType.'); + return; + } + + // Create a mock setting object for processing + const mockSetting: NotificationSetting = { + id: -1, // Indicate it's a test/mock + name: 'Test Setting', + enabled: true, + channel_type: testTargetChannelType, + config: testTargetConfig, + enabled_events: [AppEventType.TestNotification as NotificationEvent], // Doesn't really matter here + }; + + const userLang = 'zh-CN'; // TODO: Get user language preference + // For test events, use 'testNotification' as the key for i18n lookups + const translatedEvent = i18next.t(`event.${AppEventType.TestNotification}`, { lng: userLang, defaultValue: AppEventType.TestNotification }); + // --- DEBUG LOG --- + console.log(`[NotificationProcessor] Translating event key '${AppEventType.TestNotification}' for lang '${userLang}'. Result: '${translatedEvent}'`); + // --- END DEBUG LOG --- + + + this.processSingleSetting(mockSetting, AppEventType.TestNotification, payload, translatedEvent, userLang); + } + + // Processes a single setting (called by both standard and test event handlers) + private processSingleSetting( + setting: NotificationSetting, + eventType: AppEventType, // The actual event type (e.g., LOGIN_SUCCESS or TestNotification) + payload: AppEventPayload, + translatedEvent: string, + userLang: string + ) { + try { + // i18nEventKey is no longer needed for template lookup here + // const i18nEventKey = eventType === AppEventType.TestNotification ? 'testNotification' : eventType; + + const processedNotification = this.prepareNotificationContent( + setting, + // i18nEventKey, // Pass the original event type for context if needed later + eventType, + payload, + translatedEvent, // Pass the already translated event name + userLang + ); + + if (processedNotification) { + this.emit('sendNotification', processedNotification); + console.log(`[NotificationProcessor] Emitting sendNotification for ${setting.channel_type} (Setting ID: ${setting.id}, Event: ${eventType})`); + } + } catch (error) { + console.error(`[NotificationProcessor] Error preparing notification for setting ID ${setting.id} and event ${eventType}:`, error); + } + } + + + private prepareNotificationContent( + setting: NotificationSetting, + eventType: AppEventType, // Use the actual event type for context + payload: AppEventPayload, + translatedEvent: string, // The already translated event name (e.g., "登录成功") + lang: string // Keep lang for potential future use + ): ProcessedNotification | null { + + // Base data for interpolation, using the translated event name + const baseInterpolationData = { + event: translatedEvent, // Use the translated event name here! + rawEvent: eventType, // Keep original event type + timestamp: payload.timestamp.toISOString(), + // Safely stringify details, provide default + details: typeof payload.details === 'object' ? JSON.stringify(payload.details, null, 2) : (payload.details || ''), + userId: payload.userId || 'N/A', + // Flatten details for easier access in simple templates + ...(typeof payload.details === 'object' ? payload.details : {}), + // Add specific fields from details if they exist (example for setting deletion) + settingId: payload.details?.settingId, + settingName: payload.details?.name, + settingType: payload.details?.type, + }; + + + let subject: string | undefined = undefined; + let body: string = ''; + + // Define GENERIC fallback templates in code + const genericSubject = `通知: {event}`; + const genericEmailBody = `

事件: {event}

时间: {timestamp}

用户ID: {userId}

详情:

{details}
`; + const genericWebhookBody = JSON.stringify({ event: '{event}', timestamp: '{timestamp}', userId: '{userId}', details: '{details}' }); + const genericTelegramBody = `*{event}*\n时间: {timestamp}\n用户ID: {userId}\n详情:\n\`\`\`\n{details}\n\`\`\``; + + + // Use user-defined template first, then the GENERIC fallback + switch (setting.channel_type) { + case 'email': + const emailConfig = setting.config as EmailConfig; + // Use user template OR generic fallback + const subjectTemplate = emailConfig.subjectTemplate || genericSubject; + subject = this.interpolate(subjectTemplate, baseInterpolationData); + // For email body, assume user template is HTML if provided, otherwise use generic HTML fallback + // Note: EmailConfig type currently doesn't have bodyTemplate. Using generic fallback. + body = this.interpolate(genericEmailBody, baseInterpolationData); + break; + + case 'webhook': + const webhookConfig = setting.config as WebhookConfig; + // Use user template OR generic fallback + const webhookTemplate = webhookConfig.bodyTemplate || genericWebhookBody; + body = this.interpolate(webhookTemplate, baseInterpolationData); + break; + + case 'telegram': + const telegramConfig = setting.config as TelegramConfig; + // Use user template OR generic fallback + const telegramTemplate = telegramConfig.messageTemplate || genericTelegramBody; + body = this.interpolate(telegramTemplate, baseInterpolationData); + break; + + default: + console.warn(`[NotificationProcessor] Unsupported channel type: ${setting.channel_type}`); + return null; + } + + return { + channelType: setting.channel_type, + config: setting.config, + subject: subject, + body: body, + rawPayload: payload // Pass original payload for potential use in senders + }; + } + + /** + * 简单的字符串模板插值替换 + * @param template 模板字符串,例如 "Hello {name}" + * @param data 数据对象,例如 { name: "World" } + * @returns 替换后的字符串 + */ + private interpolate(template: string, data: Record): string { + if (!template) return ''; + // 使用正则表达式全局替换 {key} 格式的占位符 + return template.replace(/\{(\w+)\}/g, (match, key) => { + // 如果 data 中存在对应的 key,则返回值,否则返回原始匹配(例如 "{unknownKey}") + // Improved: Handle potential undefined/null values gracefully + return data.hasOwnProperty(key) && data[key] !== null && data[key] !== undefined ? String(data[key]) : match; + }); + } +} + +// 创建单例并导出 +// The instance is created immediately, and its async initialize method is called. +// Other parts of the app that import this will get the instance, +// but event processing won't start until initialization completes. +const notificationProcessorService = new NotificationProcessorService(); + +export default notificationProcessorService; \ No newline at end of file diff --git a/packages/backend/src/services/senders/email.sender.service.ts b/packages/backend/src/services/senders/email.sender.service.ts new file mode 100644 index 0000000..fcd3c79 --- /dev/null +++ b/packages/backend/src/services/senders/email.sender.service.ts @@ -0,0 +1,98 @@ +import nodemailer from 'nodemailer'; +import Mail from 'nodemailer/lib/mailer'; // Import Mail type for transporter +import { INotificationSender } from '../notification.dispatcher.service'; +import { ProcessedNotification } from '../notification.processor.service'; +import { EmailConfig } from '../../types/notification.types'; +import { settingsService } from '../settings.service'; // Import settingsService + +class EmailSenderService implements INotificationSender { + + async send(notification: ProcessedNotification): Promise { + const config = notification.config as EmailConfig; + const { to, subjectTemplate, smtpHost, smtpPort, smtpSecure, smtpUser, smtpPass, from } = config; + const subject = notification.subject || 'Notification'; // Use processed subject or default + const body = notification.body; // Use processed body (assuming HTML) + + if (!to) { + console.error('[EmailSender] Missing recipient address (to) in configuration.'); + throw new Error('Email configuration is incomplete (missing recipient address).'); + } + + try { + // Get global settings for fallback SMTP configuration using settingsService + const globalSmtpHost = await settingsService.getSetting('smtpHost'); + const globalSmtpPortStr = await settingsService.getSetting('smtpPort'); + const globalSmtpSecureStr = await settingsService.getSetting('smtpSecure'); + const globalSmtpUser = await settingsService.getSetting('smtpUser'); + const globalSmtpPass = await settingsService.getSetting('smtpPass'); + const globalSmtpFrom = await settingsService.getSetting('smtpFrom'); + + // Determine SMTP settings: prioritize channel-specific, then global, then defaults + const finalSmtpHost = smtpHost || globalSmtpHost; + const finalSmtpPort = smtpPort ?? (globalSmtpPortStr ? parseInt(globalSmtpPortStr, 10) : 587); // Default port 587 + const finalSmtpSecure = smtpSecure ?? (globalSmtpSecureStr === 'true') ?? false; // Default secure false + const finalSmtpUser = smtpUser || globalSmtpUser; + const finalSmtpPass = smtpPass || globalSmtpPass; + const finalFrom = from || globalSmtpFrom || 'noreply@nexus-terminal.local'; // Default from + + if (!finalSmtpHost) { + console.error('[EmailSender] SMTP host is not configured (neither channel-specific nor global).'); + throw new Error('SMTP host configuration is missing.'); + } + // Basic validation for port + if (isNaN(finalSmtpPort) || finalSmtpPort <= 0) { + console.error(`[EmailSender] Invalid SMTP port configured: ${finalSmtpPort}. Using default 587.`); + // finalSmtpPort = 587; // Or throw error depending on strictness needed + throw new Error(`Invalid SMTP port configured: ${finalSmtpPort}`); + } + + + const transporterOptions: nodemailer.TransportOptions = { + host: finalSmtpHost, + port: finalSmtpPort, + secure: finalSmtpSecure, // true for 465, false for other ports + auth: (finalSmtpUser && finalSmtpPass) ? { + user: finalSmtpUser, + pass: finalSmtpPass, + } : undefined, // Only include auth if user/pass are provided + tls: { + // Do not fail on invalid certs if secure is false or not explicitly required + rejectUnauthorized: finalSmtpSecure // Stricter check based on finalSecure value + } + }; + + const transporter = nodemailer.createTransport(transporterOptions); + + // Verify connection configuration (optional but recommended) + // try { + // await transporter.verify(); + // console.log('[EmailSender] SMTP configuration verified successfully.'); + // } catch (verifyError) { + // console.error('[EmailSender] SMTP configuration verification failed:', verifyError); + // throw new Error(`SMTP verification failed: ${verifyError.message}`); + // } + + + const mailOptions: Mail.Options = { + from: `"${finalFrom.split('@')[0]}" <${finalFrom}>`, // sender address format "Sender Name " + to: to, // list of receivers (comma-separated) + subject: subject, // Subject line + // text: 'Plain text body', // Plain text body (optional, provide if HTML is not supported well) + html: body, // html body + }; + + console.log(`[EmailSender] Sending email notification to: ${to} with subject: "${subject}"`); + const info = await transporter.sendMail(mailOptions); + console.log(`[EmailSender] Email sent successfully. Message ID: ${info.messageId}`); + + } catch (error: any) { + console.error(`[EmailSender] Error sending email notification to ${to}:`, error); + // Provide more specific error message if possible + throw new Error(`Failed to send email notification: ${error.message || error}`); + } + } +} + +// Create and export singleton instance +const emailSenderService = new EmailSenderService(); +export default emailSenderService; \ No newline at end of file diff --git a/packages/backend/src/services/senders/telegram.sender.service.ts b/packages/backend/src/services/senders/telegram.sender.service.ts new file mode 100644 index 0000000..5eab2d4 --- /dev/null +++ b/packages/backend/src/services/senders/telegram.sender.service.ts @@ -0,0 +1,53 @@ +import axios from 'axios'; +import { INotificationSender } from '../notification.dispatcher.service'; // Import the interface +import { ProcessedNotification } from '../notification.processor.service'; +import { TelegramConfig } from '../../types/notification.types'; + +class TelegramSenderService implements INotificationSender { + + async send(notification: ProcessedNotification): Promise { + const config = notification.config as TelegramConfig; + const { botToken, chatId } = config; + const messageBody = notification.body; + + if (!botToken || !chatId) { + console.error('[TelegramSender] Missing botToken or chatId in configuration.'); + throw new Error('Telegram configuration is incomplete (missing botToken or chatId).'); + } + + const apiUrl = `https://api.telegram.org/bot${botToken}/sendMessage`; + + try { + console.log(`[TelegramSender] Sending notification to chat ID: ${chatId}`); + const response = await axios.post(apiUrl, { + chat_id: chatId, + text: messageBody, + parse_mode: 'Markdown', // Use standard Markdown + disable_web_page_preview: true // Optional: disable link previews + }, { + timeout: 10000 // Set a timeout (e.g., 10 seconds) + }); + + if (response.data && response.data.ok) { + console.log(`[TelegramSender] Successfully sent notification to chat ID: ${chatId}`); + } else { + // Log Telegram's error description if available + const errorDescription = response.data?.description || 'Unknown error from Telegram API'; + console.error(`[TelegramSender] Failed to send notification. Telegram API response: ${errorDescription}`, response.data); + throw new Error(`Telegram API error: ${errorDescription}`); + } + } catch (error: any) { + if (axios.isAxiosError(error)) { + console.error(`[TelegramSender] Axios error sending notification: ${error.message}`, error.response?.data); + throw new Error(`Failed to send Telegram notification (Axios Error): ${error.message}`); + } else { + console.error(`[TelegramSender] Unexpected error sending notification:`, error); + throw new Error(`Failed to send Telegram notification (Unexpected Error): ${error.message || error}`); + } + } + } +} + +// Create and export singleton instance +const telegramSenderService = new TelegramSenderService(); +export default telegramSenderService; \ No newline at end of file diff --git a/packages/backend/src/services/senders/webhook.sender.service.ts b/packages/backend/src/services/senders/webhook.sender.service.ts new file mode 100644 index 0000000..e9be14e --- /dev/null +++ b/packages/backend/src/services/senders/webhook.sender.service.ts @@ -0,0 +1,107 @@ +import axios, { Method } from 'axios'; +import { INotificationSender } from '../notification.dispatcher.service'; +import { ProcessedNotification } from '../notification.processor.service'; +import { WebhookConfig } from '../../types/notification.types'; + +class WebhookSenderService implements INotificationSender { + + async send(notification: ProcessedNotification): Promise { + const config = notification.config as WebhookConfig; + const { url, method = 'POST', headers = {} } = config; // Default method to POST + const requestBody = notification.body; // Body is already processed by the processor + + if (!url) { + console.error('[WebhookSender] Missing webhook URL in configuration.'); + throw new Error('Webhook configuration is incomplete (missing URL).'); + } + + // Validate URL format (basic check) + try { + new URL(url); + } catch (e) { + console.error(`[WebhookSender] Invalid webhook URL format: ${url}`); + throw new Error(`Invalid webhook URL format: ${url}`); + } + + // Prepare headers + const finalHeaders: Record = { + 'Content-Type': 'application/json', // Default Content-Type, can be overridden by config + ...headers, // Merge custom headers from config + }; + + // Determine HTTP method + const requestMethod: Method = method.toUpperCase() as Method; // Ensure method is uppercase and valid Axios Method type + const validMethods: Method[] = ['GET', 'POST', 'PUT', 'DELETE', 'PATCH', 'HEAD', 'OPTIONS']; + if (!validMethods.includes(requestMethod)) { + console.error(`[WebhookSender] Invalid HTTP method specified: ${method}. Defaulting to POST.`); + // requestMethod = 'POST'; // Or throw an error + throw new Error(`Invalid HTTP method specified: ${method}`); + } + + + try { + console.log(`[WebhookSender] Sending ${requestMethod} notification to webhook URL: ${url}`); + + // Prepare request data based on method + let requestData: any = undefined; + let requestParams: any = undefined; + + // For GET requests, data is usually sent as query params. + // For POST/PUT/PATCH, data is sent in the body. + // We assume the processed `requestBody` is intended for the body. + // If the template was designed for GET params, this might need adjustment. + if (['POST', 'PUT', 'PATCH'].includes(requestMethod)) { + // Try to parse body as JSON if Content-Type suggests it, otherwise send as string + if (finalHeaders['Content-Type']?.toLowerCase().includes('application/json')) { + try { + requestData = JSON.parse(requestBody); + } catch (parseError) { + console.warn(`[WebhookSender] Failed to parse request body as JSON for Content-Type application/json. Sending as raw string. Body: ${requestBody.substring(0,100)}...`); + requestData = requestBody; + } + } else { + requestData = requestBody; + } + } else if (requestMethod === 'GET') { + // For GET, we might need to parse the body (if it's a query string) or handle differently. + // For simplicity now, we won't automatically convert body to params for GET. + // User should configure GET webhooks appropriately (e.g., URL includes params). + console.warn(`[WebhookSender] Sending data in body for GET request might not be standard. URL: ${url}`); + // If requestBody is intended as query params, parsing logic would be needed here. + // requestParams = querystring.parse(requestBody); // Example + } + + + const response = await axios({ + method: requestMethod, + url: url, + headers: finalHeaders, + data: requestData, + params: requestParams, + timeout: 15000 // Set a timeout (e.g., 15 seconds) + }); + + // Check response status (e.g., 2xx indicates success) + if (response.status >= 200 && response.status < 300) { + console.log(`[WebhookSender] Successfully sent notification to webhook. Status: ${response.status}`); + } else { + console.warn(`[WebhookSender] Webhook endpoint responded with status: ${response.status}`, response.data); + // Consider throwing an error for non-2xx responses depending on requirements + // throw new Error(`Webhook endpoint responded with status: ${response.status}`); + } + + } catch (error: any) { + if (axios.isAxiosError(error)) { + console.error(`[WebhookSender] Axios error sending notification to ${url}: ${error.message}`, error.response?.status, error.response?.data); + throw new Error(`Failed to send webhook notification (Axios Error): ${error.message}`); + } else { + console.error(`[WebhookSender] Unexpected error sending notification to ${url}:`, error); + throw new Error(`Failed to send webhook notification (Unexpected Error): ${error.message || error}`); + } + } + } +} + +// Create and export singleton instance +const webhookSenderService = new WebhookSenderService(); +export default webhookSenderService; \ No newline at end of file diff --git a/packages/frontend/src/stores/notifications.store.ts b/packages/frontend/src/stores/notifications.store.ts index d24ab17..4d4505e 100644 --- a/packages/frontend/src/stores/notifications.store.ts +++ b/packages/frontend/src/stores/notifications.store.ts @@ -82,8 +82,8 @@ export const useNotificationsStore = defineStore('notifications', () => { // The component handles its own 'testingNotification' state. error.value = null; // Clear previous general errors try { - // Send the config to test in the request body - const response = await apiClient.post<{ message: string }>(`/notifications/${id}/test`, { config }); // 使用 apiClient + // Send the request without a body, as the backend uses the saved config for the given ID + const response = await apiClient.post<{ message: string }>(`/notifications/${id}/test`); // 使用 apiClient, removed config from body return { success: true, message: response.data.message || '测试成功' }; } catch (err: any) { console.error(`Error testing notification setting ${id}:`, err);