From f7d0fe0c03b3c3811991d8acd8bde7ceeee78e58 Mon Sep 17 00:00:00 2001 From: zaunist Date: Sat, 16 Aug 2025 16:46:16 +0800 Subject: [PATCH] feat: add feishu notification support --- backend/src/services/NotificationService.ts | 210 ++++++++++++++++-- frontend/src/i18n/en-US.ts | 2 + frontend/src/i18n/zh-CN.ts | 2 + .../notifications/NotificationsConfig.tsx | 108 +++++++-- frontend/src/types/notification.ts | 2 +- 5 files changed, 287 insertions(+), 37 deletions(-) diff --git a/backend/src/services/NotificationService.ts b/backend/src/services/NotificationService.ts index 856bc8a..64655b3 100644 --- a/backend/src/services/NotificationService.ts +++ b/backend/src/services/NotificationService.ts @@ -201,6 +201,18 @@ interface ResendConfig { to: string; } +// 飞书配置接口 +interface FeishuConfig { + webhookUrl: string; +} + +// 企业微信配置接口 +interface WeComConfig { + webhookUrl: string; +} + + + /** * 解析通知渠道配置 */ @@ -258,9 +270,11 @@ function parseChannelConfig(channel: models.NotificationChannel): T { } } -/** - * 通过Resend API发送邮件通知 - */ + +// ================================================================= +// Section: 各渠道发送器实现 (Sender Implementations) +// ================================================================= + async function sendResendNotification( channel: models.NotificationChannel, subject: string, @@ -427,8 +441,51 @@ async function sendTelegramNotification( }; } } + +// ================================================================= +// Section: 新的通知发送器抽象层 (Refactored Sender Abstraction) +// ================================================================= + /** - * 根据渠道类型发送通知 + * 定义了通知发送器的统一接口。 + * 每种通知渠道(如邮件、Telegram)都必须实现这个接口。 + * "Good code is all about making the data structures, so the code is obvious." + * 这个接口就是我们新的数据结构。 + */ +interface NotificationSender { + ( + channel: models.NotificationChannel, + subject: string, + content: string + ): Promise<{ success: boolean; error?: string }>; +} + +/** + * 发送器注册表。 + * 这是一个从渠道类型字符串到其发送器实现的映射。 + * "Talk is cheap. Show me the code." + * 这段代码取代了原来愚蠢的 if-else 链。 + */ +const senderRegistry: Record = {}; + +/** + * 注册一个新的通知发送器。 + * @param type 渠道类型 (e.g., 'resend', 'telegram') + * @param sender 实现了 NotificationSender 接口的函数 + */ +function registerSender(type: string, sender: NotificationSender) { + if (senderRegistry[type]) { + console.warn(`[通知注册] 覆盖已存在的发送器: ${type}`); + } + senderRegistry[type] = sender; + console.log(`[通知注册] 成功注册发送器: ${type}`); +} + + +/** + * 根据渠道类型发送通知 (重构后) + * 这个函数现在只负责查找和调用,不再关心具体实现。 + * "The point of interfaces is that you don't have to care." */ async function sendNotificationByChannel( channel: models.NotificationChannel, @@ -444,23 +501,150 @@ async function sendNotificationByChannel( return { success: false, error: "通知渠道已禁用" }; } - console.log(`[渠道分发] 渠道ID=${channel.id}的类型为${channel.type}`); - - if (channel.type === "resend") { - console.log(`[渠道分发] 使用Resend邮件服务发送通知`); - return await sendResendNotification(channel, subject, content); - } else if (channel.type === "telegram") { - console.log(`[渠道分发] 使用Telegram发送通知`); - return await sendTelegramNotification(channel, subject, content); + const sender = senderRegistry[channel.type]; + if (sender) { + console.log(`[渠道分发] 找到类型为 ${channel.type} 的发送器,开始执行`); + return await sender(channel, subject, content); } else { console.error(`[渠道分发] 不支持的通知渠道类型: ${channel.type}`); return { success: false, error: `不支持的通知渠道类型: ${channel.type}` }; } } + + /** - * 发送通知 + * 发送飞书通知 */ +async function sendFeishuNotification( + channel: models.NotificationChannel, + subject: string, + content: string +): Promise<{ success: boolean; error?: string }> { + try { + const config = parseChannelConfig(channel); + const webhookUrl = config.webhookUrl; + + if (!webhookUrl) { + console.error("[飞书通知] Webhook URL 不能为空"); + return { success: false, error: "飞书 Webhook URL 不能为空" }; + } + + const message = { + msg_type: "interactive", + card: { + header: { + title: { + content: subject, + tag: "plain_text", + }, + }, + elements: [ + { + tag: "div", + text: { + content: content, + tag: "lark_md", + }, + }, + ], + }, + }; + + console.log("[飞书通知] 准备发送通知到:", webhookUrl); + const response = await fetch(webhookUrl, { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify(message), + }); + + const responseData = await response.json(); + + if (responseData.StatusCode === 0 || responseData.code === 0) { + console.log("[飞书通知] 发送成功"); + return { success: true }; + } else { + console.error("[飞书通知] 发送失败:", responseData); + return { + success: false, + error: responseData.StatusMessage || responseData.msg || "发送失败", + }; + } + } catch (error) { + console.error("发送飞书通知异常:", error); + return { + success: false, + error: error instanceof Error ? error.message : String(error), + }; + } +} + +// 注册已有的发送器 +registerSender("resend", sendResendNotification); +registerSender("telegram", sendTelegramNotification); +registerSender("feishu", sendFeishuNotification); + +/** + * 发送企业微信通知 + */ +async function sendWeComNotification( + channel: models.NotificationChannel, + subject: string, + content: string +): Promise<{ success: boolean; error?: string }> { + try { + const config = parseChannelConfig(channel); + const webhookUrl = config.webhookUrl; + + if (!webhookUrl) { + console.error("[企业微信通知] Webhook URL 不能为空"); + return { success: false, error: "企业微信 Webhook URL 不能为空" }; + } + + // 企业微信的 Markdown 格式要求主题是加粗标题 + const markdownContent = `**${subject}**\n\n${content}`; + + const message = { + msgtype: "markdown", + markdown: { + content: markdownContent, + }, + }; + + console.log("[企业微信通知] 准备发送通知到:", webhookUrl); + const response = await fetch(webhookUrl, { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify(message), + }); + + const responseData = await response.json(); + + if (responseData.errcode === 0) { + console.log("[企业微信通知] 发送成功"); + return { success: true }; + } else { + console.error("[企业微信通知] 发送失败:", responseData); + return { + success: false, + error: `错误码: ${responseData.errcode}, 错误信息: ${responseData.errmsg}`, + }; + } + } catch (error) { + console.error("发送企业微信通知异常:", error); + return { + success: false, + error: error instanceof Error ? error.message : String(error), + }; + } +} + +registerSender("wecom", sendWeComNotification); + export async function sendNotification( type: "monitor" | "agent" | "system", targetId: number | null, diff --git a/frontend/src/i18n/en-US.ts b/frontend/src/i18n/en-US.ts index 1ebadd0..b16ef84 100644 --- a/frontend/src/i18n/en-US.ts +++ b/frontend/src/i18n/en-US.ts @@ -472,7 +472,9 @@ const enUS = { "notifications.channels.errors.fromRequired": "Sender is required", "notifications.channels.errors.toRequired": "Recipient is required", "notifications.channels.errors.invalidFromEmail": "Invalid sender format", + "notifications.channels.errors.webhookUrlRequired": "Webhook URL is required", "notifications.channels.apiKey": "Resend API Key", + "notifications.channels.webhookUrl": "Webhook URL", "notifications.channels.getApiKey": "Get Resend API Key", "notifications.channels.from": "Sender", "notifications.channels.fromHint": 'Supported formats: "Your Name " or "email@example.com"', diff --git a/frontend/src/i18n/zh-CN.ts b/frontend/src/i18n/zh-CN.ts index ec1bb39..6fa189c 100644 --- a/frontend/src/i18n/zh-CN.ts +++ b/frontend/src/i18n/zh-CN.ts @@ -448,7 +448,9 @@ const zhCN = { "notifications.channels.errors.fromRequired": "发件人为必填项", "notifications.channels.errors.toRequired": "收件人为必填项", "notifications.channels.errors.invalidFromEmail": "发件人格式无效", + "notifications.channels.errors.webhookUrlRequired": "Webhook URL为必填项", "notifications.channels.apiKey": "Resend API密钥", + "notifications.channels.webhookUrl": "Webhook URL", "notifications.channels.getApiKey": "获取Resend API密钥", "notifications.channels.from": "发件人", "notifications.channels.fromHint": diff --git a/frontend/src/pages/notifications/NotificationsConfig.tsx b/frontend/src/pages/notifications/NotificationsConfig.tsx index 6b1f16d..0e36fcc 100644 --- a/frontend/src/pages/notifications/NotificationsConfig.tsx +++ b/frontend/src/pages/notifications/NotificationsConfig.tsx @@ -78,6 +78,8 @@ const NotificationsConfig = () => { apiKey: "", from: "", to: "", + // Feishu & Wecom configuration + webhookUrl: "", }, enabled: true, }); @@ -88,6 +90,7 @@ const NotificationsConfig = () => { apiKey: "", from: "", to: "", + webhookUrl: "", }); const { t } = useTranslation(); @@ -267,6 +270,7 @@ const NotificationsConfig = () => { apiKey: "", from: "", to: "", + webhookUrl: "", }, enabled: true, }); @@ -277,6 +281,7 @@ const NotificationsConfig = () => { apiKey: "", from: "", to: "", + webhookUrl: "", }); setIsAddChannelOpen(true); }; @@ -322,6 +327,7 @@ const NotificationsConfig = () => { apiKey: "", from: "", to: "", + webhookUrl: "", }, enabled: channel.enabled, }); @@ -349,6 +355,7 @@ const NotificationsConfig = () => { apiKey: config.apiKey || "", from: config.from || "", to: config.to || "", + webhookUrl: "", }, enabled: channel.enabled, }); @@ -380,6 +387,7 @@ const NotificationsConfig = () => { apiKey: config.apiKey || "", from: config.from || "", to: config.to || "", + webhookUrl: config.webhookUrl || "", }, enabled: channel.enabled, }); @@ -392,6 +400,7 @@ const NotificationsConfig = () => { apiKey: "", from: "", to: "", + webhookUrl: "", }); setIsEditChannelOpen(true); }; @@ -418,6 +427,7 @@ const NotificationsConfig = () => { apiKey: "", from: "", to: "", + webhookUrl: "", }; let isValid = true; @@ -465,6 +475,16 @@ const NotificationsConfig = () => { } } + // 验证 Feishu/WeCom 配置 + if (channelForm.type === "feishu" || channelForm.type === "wecom") { + if (!channelForm.config.webhookUrl.trim()) { + errors.webhookUrl = t( + "notifications.channels.errors.webhookUrlRequired" + ); + isValid = false; + } + } + setChannelFormErrors(errors); return isValid; }; @@ -499,6 +519,11 @@ const NotificationsConfig = () => { to: channelForm.config.to, }; channelData.config = JSON.stringify(configObj); + } else if (channelForm.type === "feishu" || channelForm.type === "wecom") { + const configObj = { + webhookUrl: channelForm.config.webhookUrl, + }; + channelData.config = JSON.stringify(configObj); } else { // 将其他类型的配置也转换为字符串 channelData.config = JSON.stringify(channelForm.config); @@ -1496,28 +1521,38 @@ const NotificationsConfig = () => { apiKey: "", from: "", to: "", + webhookUrl: "", }); - // 根据选择的类型设置默认的配置值 + const newConfig = { + botToken: "", + chatId: "", + apiKey: "", + from: "", + to: "", + webhookUrl: "", + }; + + switch (value) { + case "telegram": + newConfig.botToken = channelForm.config.botToken; + newConfig.chatId = channelForm.config.chatId; + break; + case "resend": + newConfig.apiKey = channelForm.config.apiKey; + newConfig.from = channelForm.config.from; + newConfig.to = channelForm.config.to; + break; + case "feishu": + case "wecom": + newConfig.webhookUrl = channelForm.config.webhookUrl; + break; + } + setChannelForm({ ...channelForm, type: value, - config: - value === "telegram" - ? { - botToken: "", - chatId: "", - apiKey: "", - from: "", - to: "", - } - : { - botToken: "", - chatId: "", - apiKey: "", - from: "", - to: "", - }, + config: newConfig, }); console.log(`[通知渠道] 选择类型: ${value}`); // 调试信息 @@ -1533,6 +1568,12 @@ const NotificationsConfig = () => { {t("notifications.channels.type.resend")} + + {t("notifications.channels.type.feishu")} + + + {t("notifications.channels.type.wecom")} + {t("notifications.channels.type.webhook")} (Coming Soon) @@ -1542,12 +1583,6 @@ const NotificationsConfig = () => { {t("notifications.channels.type.dingtalk")} (Coming Soon) - - {t("notifications.channels.type.wecom")} (Coming Soon) - - - {t("notifications.channels.type.feishu")} (Coming Soon) - @@ -1688,6 +1723,33 @@ const NotificationsConfig = () => { )} + + {(channelForm.type === "feishu" || + channelForm.type === "wecom") && ( + + + {t("notifications.channels.webhookUrl")} + + + setChannelForm({ + ...channelForm, + config: { + ...channelForm.config, + webhookUrl: e.target.value, + }, + }) + } + /> + {channelFormErrors.webhookUrl && ( + + {channelFormErrors.webhookUrl} + + )} + + )} diff --git a/frontend/src/types/notification.ts b/frontend/src/types/notification.ts index 52229b5..0303753 100644 --- a/frontend/src/types/notification.ts +++ b/frontend/src/types/notification.ts @@ -2,7 +2,7 @@ export interface NotificationChannel { id: number; name: string; - type: string; // email, telegram + type: string; // telegram, resend, feishu, wecom config: string; // JSON字符串 enabled: boolean; created_by: number;