feat: 定时通知新增飞书自定义机器人渠道

原定时通知仅支持邮箱与 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 同步通知渠道类型注释
This commit is contained in:
2026-10-13 12:49:39 +08:00
parent 6439d336d4
commit 4aeeaac94f
10 changed files with 235 additions and 18 deletions
+1
View File
@@ -19,6 +19,7 @@ export interface NotificationChannel {
enabled: boolean
config: Record<string, any>
hasPassword: boolean
hasSecret: boolean
createdAt: string
updatedAt: string
}
@@ -69,6 +69,7 @@ const testingId = ref<number | null>(null)
const showChannelModal = ref(false)
const editingId = ref<number | null>(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<string, any> = { webhookUrl: form.webhookUrl.trim() }
if (form.secret) {
config.secret = form.secret
}
return config
}
const config: Record<string, any> = {
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)
</NFormItem>
</template>
<template v-else-if="channelForm.type === 'FEISHU'">
<NFormItem :label="t('notification.webhookUrl')">
<NInput v-model:value="channelForm.webhookUrl" :placeholder="t('notification.webhookUrlPlaceholder')" />
</NFormItem>
<NFormItem :label="t('notification.secret')">
<NInput
v-model:value="channelForm.secret"
type="password"
show-password-on="click"
:placeholder="editingHasSecret ? t('notification.secretKeep') : t('notification.secretPlaceholder')"
/>
</NFormItem>
</template>
<template v-else>
<NFormItem :label="t('notification.smtpHost')">
<NInput v-model:value="channelForm.smtpHost" placeholder="smtp.qq.com" />
+26 -3
View File
@@ -437,9 +437,9 @@ export default {
statusOn: { zh: '已开启', en: 'Enabled', ja: '有効' },
statusOff: { zh: '已关闭', en: 'Disabled', ja: '無効' },
intro: {
zh: '按自定义周期,通过邮箱或 Server 酱提醒你及时导入新的账单。',
en: 'Get reminded via email or ServerChan to import new bills on your own schedule.',
ja: 'カスタム周期でメール・ServerChan を通じて、新しい明細のインポートをお知らせします。',
zh: '按自定义周期,通过邮箱、Server 酱或飞书提醒你及时导入新的账单。',
en: 'Get reminded via email, ServerChan, or Feishu to import new bills on your own schedule.',
ja: 'カスタム周期でメール・ServerChan・Feishu を通じて、新しい明細のインポートをお知らせします。',
},
linkUrl: { zh: '通知链接地址', en: 'Notification link URL', ja: '通知リンク URL' },
linkUrlPlaceholder: {
@@ -501,6 +501,7 @@ export default {
disabledLabel: { zh: '已停用', en: 'Disabled', ja: '無効' },
typeEmail: { zh: '邮箱', en: 'Email', ja: 'メール' },
typeServerChan: { zh: 'Server 酱', en: 'ServerChan', ja: 'ServerChan' },
typeFeishu: { zh: '飞书', en: 'Feishu', ja: 'Feishu' },
smtpHost: { zh: 'SMTP 服务器', en: 'SMTP host', ja: 'SMTP サーバー' },
smtpPort: { zh: 'SMTP 端口', en: 'SMTP port', ja: 'SMTP ポート' },
username: { zh: '邮箱账号', en: 'Email account', ja: 'メールアカウント' },
@@ -520,6 +521,28 @@ export default {
en: 'Please enter the ServerChan SendKey',
ja: 'ServerChan の SendKey を入力してください',
},
webhookUrl: { zh: 'Webhook 地址', en: 'Webhook URL', ja: 'Webhook URL' },
webhookUrlPlaceholder: {
zh: '飞书机器人 Webhook 完整地址',
en: 'Full webhook URL of the Feishu bot',
ja: 'Feishu ボットの Webhook URL(完全なもの)',
},
webhookUrlRequired: {
zh: '请填写飞书机器人 Webhook 地址',
en: 'Please enter the Feishu bot webhook URL',
ja: 'Feishu ボットの Webhook URL を入力してください',
},
secret: { zh: '签名密钥', en: 'Signing secret', ja: '署名シークレット' },
secretPlaceholder: {
zh: '开启签名校验时填写,否则留空',
en: 'Enter if signature verification is enabled; otherwise leave blank',
ja: '署名認証が有効な場合に入力。それ以外は空欄',
},
secretKeep: {
zh: '已配置,留空表示不修改',
en: 'Configured; leave blank to keep',
ja: '設定済み。空欄なら変更しません',
},
password: { zh: '授权码', en: 'Auth code', ja: '認証コード' },
passwordPlaceholder: { zh: '邮箱 SMTP 授权码', en: 'SMTP authorization code', ja: 'SMTP 認証コード' },
passwordKeep: {