From 4aeeaac94f2273c750bed27d648919cdc95896b4 Mon Sep 17 00:00:00 2001 From: rosercode Date: Tue, 13 Oct 2026 12:49:39 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=AE=9A=E6=97=B6=E9=80=9A=E7=9F=A5?= =?UTF-8?q?=E6=96=B0=E5=A2=9E=E9=A3=9E=E4=B9=A6=E8=87=AA=E5=AE=9A=E4=B9=89?= =?UTF-8?q?=E6=9C=BA=E5=99=A8=E4=BA=BA=E6=B8=A0=E9=81=93?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 原定时通知仅支持邮箱与 Server 酱,现新增飞书自定义机器人 Webhook 渠道,用户填写机器人地址(可选签名密钥)后即可将账单导入提醒推送到飞书群。消息以 post 富文本发送,标题保留为卡片标题、正文按行分段;开启签名校验的机器人会按飞书规则附带 timestamp 与 HMAC-SHA256 签名。飞书密钥与邮箱授权码一致处理:留空表示不修改,返回前端时脱敏。 变更内容: - 后端新增 NotificationChannelType.FEISHU 与 FeishuNotificationSender(post 富文本、可选加签、兼容 code/StatusCode 响应) - NotificationChannelServiceImpl 将敏感字段抽为 password/secret 集合,统一“留空保留原值”与脱敏逻辑,并补充 FEISHU 默认渠道名 - NotificationChannelVO 新增 hasSecret,返回时移除 secret 并以标记提示是否已配置 - 前端 api/notification.ts 补充 hasSecret,NotificationConfigPanel 增加飞书类型与 Webhook/签名密钥表单项及必填校验 - locales/messages.ts 新增飞书相关 zh/en/ja 文案并更新面板说明文案 - init.sql 同步通知渠道类型注释 --- frontend/src/api/notification.ts | 1 + .../components/NotificationConfigPanel.vue | 37 ++++- frontend/src/locales/messages.ts | 29 +++- .../dto/NotificationChannelRequest.java | 4 +- .../ledgerly/entity/NotificationChannel.java | 2 +- .../enums/NotificationChannelType.java | 5 +- .../FeishuNotificationSender.java | 131 ++++++++++++++++++ .../impl/NotificationChannelServiceImpl.java | 35 ++++- .../ledgerly/vo/NotificationChannelVO.java | 7 +- src/main/resources/sql/init.sql | 2 +- 10 files changed, 235 insertions(+), 18 deletions(-) create mode 100644 src/main/java/cn/rosercode/ledgerly/notification/FeishuNotificationSender.java diff --git a/frontend/src/api/notification.ts b/frontend/src/api/notification.ts index 275a43d..ea9eb59 100644 --- a/frontend/src/api/notification.ts +++ b/frontend/src/api/notification.ts @@ -19,6 +19,7 @@ export interface NotificationChannel { enabled: boolean config: Record hasPassword: boolean + hasSecret: boolean createdAt: string updatedAt: string } diff --git a/frontend/src/components/NotificationConfigPanel.vue b/frontend/src/components/NotificationConfigPanel.vue index bc307d8..565a167 100644 --- a/frontend/src/components/NotificationConfigPanel.vue +++ b/frontend/src/components/NotificationConfigPanel.vue @@ -69,6 +69,7 @@ const testingId = ref(null) const showChannelModal = ref(false) const editingId = ref(null) const editingHasPassword = ref(false) +const editingHasSecret = ref(false) const savingChannel = ref(false) const channelForm = ref(emptyChannelForm()) @@ -85,6 +86,8 @@ function emptyChannelForm() { password: '', to: '', ssl: true, + webhookUrl: '', + secret: '', } } @@ -107,16 +110,18 @@ const weekOptions = computed(() => [ // 每月提醒日限制在 1-28,避免不同月份天数差异 const monthDayOptions = computed(() => Array.from({ length: 28 }, (_, i) => ({ label: String(i + 1), value: i + 1 }))) -// 前端开放 Server 酱与邮箱两种推送渠道 +// 前端开放 Server 酱、邮箱与飞书两种推送渠道 const channelTypeOptions = computed(() => [ { label: t('notification.typeServerChan'), value: 'SERVERCHAN' }, { label: t('notification.typeEmail'), value: 'EMAIL' }, + { label: t('notification.typeFeishu'), value: 'FEISHU' }, ]) /** 渠道类型显示名 */ function channelTypeLabel(type: string | null) { if (type === 'SERVERCHAN') return t('notification.typeServerChan') if (type === 'EMAIL') return t('notification.typeEmail') + if (type === 'FEISHU') return t('notification.typeFeishu') return type || '' } @@ -124,6 +129,7 @@ function channelTypeLabel(type: string | null) { function channelTagType(type: string) { if (type === 'SERVERCHAN') return 'success' if (type === 'EMAIL') return 'warning' + if (type === 'FEISHU') return 'primary' return 'info' } @@ -193,6 +199,7 @@ async function handleSaveConfig() { function openChannelModal(channel: NotificationChannel | null) { editingId.value = channel ? channel.id : null editingHasPassword.value = !!channel?.hasPassword + editingHasSecret.value = !!channel?.hasSecret const base = emptyChannelForm() if (channel) { const cfg = channel.config || {} @@ -207,6 +214,8 @@ function openChannelModal(channel: NotificationChannel | null) { password: '', to: cfg.to || '', ssl: cfg.ssl !== false, + webhookUrl: cfg.webhookUrl || '', + secret: '', }) } channelForm.value = base @@ -219,6 +228,13 @@ function buildChannelConfig() { if (form.type === 'SERVERCHAN') { return { sendKey: form.sendKey.trim() } } + if (form.type === 'FEISHU') { + const config: Record = { webhookUrl: form.webhookUrl.trim() } + if (form.secret) { + config.secret = form.secret + } + return config + } const config: Record = { smtpHost: form.smtpHost.trim(), smtpPort: form.smtpPort, @@ -240,6 +256,11 @@ async function handleSaveChannel() { message.error(t('notification.sendKeyRequired')) return } + } else if (form.type === 'FEISHU') { + if (!form.webhookUrl.trim()) { + message.error(t('notification.webhookUrlRequired')) + return + } } else if (!form.username.trim()) { message.error(t('notification.usernameRequired')) return @@ -475,6 +496,20 @@ onMounted(loadAll) + +