From a85e253b459c93af9808743c5568a534594502ab Mon Sep 17 00:00:00 2001 From: duorameng <2997944583@qq.com> Date: Tue, 7 Apr 2026 21:34:36 +0800 Subject: [PATCH] feat: add message tmplate #76 --- internal/constant/constant.go | 32 +++ internal/controllers/settings_controller.go | 33 +++ internal/router/api_routes.go | 2 + internal/services/notification_service.go | 127 +++++++-- internal/services/settings_service.go | 11 + web/src/api/index.ts | 3 + web/src/assets/index.css | 4 + web/src/views/notify/Notify.vue | 15 +- .../notify/components/TemplateSettings.vue | 240 ++++++++++++++++++ 9 files changed, 443 insertions(+), 24 deletions(-) create mode 100644 web/src/views/notify/components/TemplateSettings.vue diff --git a/internal/constant/constant.go b/internal/constant/constant.go index 8f07961..c78b359 100644 --- a/internal/constant/constant.go +++ b/internal/constant/constant.go @@ -68,6 +68,21 @@ const ( KeyNotifyChannels = "channels" KeyNotifyEvents = "events" KeyNotifyToken = "notify_token" + KeyNotifyPrefix = "notify_prefix" + + // Notify Templates Keys + KeyNotifyTemplateUserLoginTitle = "notify_template_user_login_title" + KeyNotifyTemplateUserLoginText = "notify_template_user_login_text" + KeyNotifyTemplateBruteForceLoginTitle = "notify_template_brute_force_login_title" + KeyNotifyTemplateBruteForceLoginText = "notify_template_brute_force_login_text" + KeyNotifyTemplatePasswordChangedTitle = "notify_template_password_changed_title" + KeyNotifyTemplatePasswordChangedText = "notify_template_password_changed_text" + KeyNotifyTemplateTaskSuccessTitle = "notify_template_task_success_title" + KeyNotifyTemplateTaskSuccessText = "notify_template_task_success_text" + KeyNotifyTemplateTaskFailedTitle = "notify_template_task_failed_title" + KeyNotifyTemplateTaskFailedText = "notify_template_task_failed_text" + KeyNotifyTemplateTaskTimeoutTitle = "notify_template_task_timeout_title" + KeyNotifyTemplateTaskTimeoutText = "notify_template_task_timeout_text" // 事件绑定类型 BindingTypeSystem = "system" @@ -186,4 +201,21 @@ var DefaultSettings = map[string]map[string]string{ KeyQueueSize: "100", KeyRateInterval: "200", }, + SectionNotify: { + KeyNotifyPrefix: "[白虎面板]", + // Login + KeyNotifyTemplateUserLoginTitle: "用户登录(成功/失败)", + KeyNotifyTemplateUserLoginText: "用户 {{username}} 在 IP {{ip}} 登录{{status_label}}\n{{message}}", + KeyNotifyTemplateBruteForceLoginTitle: "系统安全警告", + KeyNotifyTemplateBruteForceLoginText: "检测到 IP {{ip}} 正在尝试暴力破解用户 {{username}}", + KeyNotifyTemplatePasswordChangedTitle: "账户安全通知", + KeyNotifyTemplatePasswordChangedText: "用户 {{username}} 刚刚修改了密码", + // Task + KeyNotifyTemplateTaskSuccessTitle: "任务[{{task_name}}] 成功", + KeyNotifyTemplateTaskSuccessText: "任务 #{{task_id}} {{task_name}}\n状态: 成功\n耗时: {{duration}}ms\n执行结果: {{output}}", + KeyNotifyTemplateTaskFailedTitle: "任务[{{task_name}}] 失败", + KeyNotifyTemplateTaskFailedText: "任务 #{{task_id}} {{task_name}}\n状态: 失败\n执行时间: {{start_time}}\n原因: {{error}}\n最后输出: {{output}}", + KeyNotifyTemplateTaskTimeoutTitle: "任务[{{task_name}}] 超时", + KeyNotifyTemplateTaskTimeoutText: "任务 #{{task_id}} {{task_name}}\n状态: 超时\n耗时: {{duration}}ms\n最后输出: {{output}}", + }, } diff --git a/internal/controllers/settings_controller.go b/internal/controllers/settings_controller.go index e24d92d..72aee83 100644 --- a/internal/controllers/settings_controller.go +++ b/internal/controllers/settings_controller.go @@ -494,6 +494,39 @@ func (sc *SettingsController) RestoreBackup(c *gin.Context) { utils.SuccessMsg(c, "恢复成功") } +// GetSectionSettings 获取指定 section 的所有设置 +func (sc *SettingsController) GetSectionSettings(c *gin.Context) { + section := c.Param("section") + if section == "" { + utils.BadRequest(c, "参数错误") + return + } + settings := sc.settingsService.GetSection(section) + utils.Success(c, settings) +} + +// UpdateSectionSettings 批量更新指定 section 的设置 +func (sc *SettingsController) UpdateSectionSettings(c *gin.Context) { + section := c.Param("section") + if section == "" { + utils.BadRequest(c, "参数错误") + return + } + + var values map[string]string + if err := c.ShouldBindJSON(&values); err != nil { + utils.BadRequest(c, "参数错误") + return + } + + if err := sc.settingsService.SetSection(section, values); err != nil { + utils.ServerError(c, "更新失败") + return + } + + utils.SuccessMsg(c, "保存成功") +} + // GetSetting 获取单个设置值 func (sc *SettingsController) GetSetting(c *gin.Context) { section := c.Param("section") diff --git a/internal/router/api_routes.go b/internal/router/api_routes.go index 6e11eef..58aebef 100644 --- a/internal/router/api_routes.go +++ b/internal/router/api_routes.go @@ -163,6 +163,8 @@ func registerSettingsRoutes(g *gin.RouterGroup, c *Controllers) { settings.GET("/backup/download", c.Settings.DownloadBackup) settings.POST("/restore", c.Settings.RestoreBackup) // 通用设置接口 + settings.GET("/:section", c.Settings.GetSectionSettings) + settings.PUT("/:section", c.Settings.UpdateSectionSettings) settings.GET("/:section/:key", c.Settings.GetSetting) settings.POST("/:section/:key/generate", c.Settings.GenerateSettingToken) } diff --git a/internal/services/notification_service.go b/internal/services/notification_service.go index 1f45bbb..9c44c32 100644 --- a/internal/services/notification_service.go +++ b/internal/services/notification_service.go @@ -15,6 +15,7 @@ import ( "github.com/engigu/baihu-panel/internal/sdk/messenger" "gorm.io/gorm" "regexp" + "strings" ) // NotifyChannel 通知渠道配置 @@ -324,6 +325,54 @@ func stripAnsi(str string) string { return ansiRegexp.ReplaceAllString(str, "") } +// parseTemplate 简单的 {{key}} 模板替换 +func (s *NotificationService) parseTemplate(tmpl string, payload map[string]interface{}) string { + result := tmpl + for k, v := range payload { + placeholder := fmt.Sprintf("{{%s}}", k) + valStr := fmt.Sprintf("%v", v) + result = strings.ReplaceAll(result, placeholder, valStr) + } + return result +} + +// getDefaultMessage 兜底默认消息内容 +func (s *NotificationService) getDefaultMessage(eventType string, payload map[string]interface{}) (string, string) { + var title, text string + switch eventType { + case constant.EventUserLogin: + status, _ := payload["status"].(string) + if status == "success" { + title = "用户登录成功" + text = fmt.Sprintf("用户 %v 在 IP %v 登录成功", payload["username"], payload["ip"]) + } else { + title = "用户登录失败" + reason, _ := payload["message"].(string) + text = fmt.Sprintf("用户 %v 在 IP %v 登录失败\n原因: %v", payload["username"], payload["ip"], reason) + } + case constant.EventBruteForceLogin: + title = "系统安全警告" + text = fmt.Sprintf("检测到 IP %v 正在尝试暴力破解用户 %v", payload["ip"], payload["username"]) + case constant.EventPasswordChanged: + title = "账户安全通知" + text = fmt.Sprintf("用户 %v 刚刚修改了密码", payload["username"]) + case constant.EventTaskSuccess: + title = fmt.Sprintf("任务[%v] 成功", payload["task_name"]) + text = fmt.Sprintf("任务 #%v %v\n状态: 成功\n执行时间: %v\n耗时: %vms", payload["task_id"], payload["task_name"], payload["start_time"], payload["duration"]) + case constant.EventTaskFailed: + title = fmt.Sprintf("任务[%v] 失败", payload["task_name"]) + if errStr, ok := payload["error"]; ok { + text = fmt.Sprintf("任务 #%v %v\n执行失败\n执行时间: %v\n错误: %v", payload["task_id"], payload["task_name"], payload["start_time"], errStr) + } else { + text = fmt.Sprintf("任务 #%v %v\n执行失败\n状态: %v\n执行时间: %v\n耗时: %vms", payload["task_id"], payload["task_name"], payload["status"], payload["start_time"], payload["duration"]) + } + case constant.EventTaskTimeout: + title = fmt.Sprintf("任务[%v] 超时", payload["task_name"]) + text = fmt.Sprintf("任务 #%v %v\n执行超时\n执行时间: %v\n耗时: %vms", payload["task_id"], payload["task_name"], payload["start_time"], payload["duration"]) + } + return title, text +} + // handleEvent 处理事件订阅并发送通知 func (s *NotificationService) handleEvent(bindingType string) eventbus.Handler { return func(e eventbus.Event) { @@ -338,36 +387,52 @@ func (s *NotificationService) handleEvent(bindingType string) eventbus.Handler { } var title, text string + + // 获取全局前缀和模板配置 + prefix := s.settingsService.Get(constant.SectionNotify, constant.KeyNotifyPrefix) + var tmplTitleKey, tmplTextKey string + switch e.Type { case constant.EventUserLogin: + tmplTitleKey = constant.KeyNotifyTemplateUserLoginTitle + tmplTextKey = constant.KeyNotifyTemplateUserLoginText + // 特殊处理登录状态 status, _ := payload["status"].(string) if status == "success" { - title = "用户登录成功" - text = fmt.Sprintf("用户 %v 在 IP %v 登录成功", payload["username"], payload["ip"]) + payload["status_label"] = "成功" } else { - title = "用户登录失败" - reason, _ := payload["message"].(string) - text = fmt.Sprintf("用户 %v 在 IP %v 登录失败\n原因: %v", payload["username"], payload["ip"], reason) + payload["status_label"] = "失败" } + case constant.EventBruteForceLogin: - title = "系统安全警告" - text = fmt.Sprintf("检测到 IP %v 正在尝试暴力破解用户 %v", payload["ip"], payload["username"]) + tmplTitleKey = constant.KeyNotifyTemplateBruteForceLoginTitle + tmplTextKey = constant.KeyNotifyTemplateBruteForceLoginText + case constant.EventPasswordChanged: - title = "账户安全通知" - text = fmt.Sprintf("用户 %v 刚刚修改了密码", payload["username"]) - case constant.EventTaskSuccess: - title = fmt.Sprintf("任务[%v] 成功", payload["task_name"]) - text = fmt.Sprintf("任务 #%v %v\n状态: 成功\n执行时间: %v\n耗时: %vms", payload["task_id"], payload["task_name"], payload["start_time"], payload["duration"]) - case constant.EventTaskFailed: - title = fmt.Sprintf("任务[%v] 失败", payload["task_name"]) - if errStr, ok := payload["error"]; ok { - text = fmt.Sprintf("任务 #%v %v\n执行失败\n执行时间: %v\n错误: %v", payload["task_id"], payload["task_name"], payload["start_time"], errStr) + tmplTitleKey = constant.KeyNotifyTemplatePasswordChangedTitle + tmplTextKey = constant.KeyNotifyTemplatePasswordChangedText + + case constant.EventTaskSuccess, constant.EventTaskFailed, constant.EventTaskTimeout: + if e.Type == constant.EventTaskSuccess { + tmplTitleKey = constant.KeyNotifyTemplateTaskSuccessTitle + tmplTextKey = constant.KeyNotifyTemplateTaskSuccessText + } else if e.Type == constant.EventTaskFailed { + tmplTitleKey = constant.KeyNotifyTemplateTaskFailedTitle + tmplTextKey = constant.KeyNotifyTemplateTaskFailedText } else { - text = fmt.Sprintf("任务 #%v %v\n执行失败\n状态: %v\n执行时间: %v\n耗时: %vms", payload["task_id"], payload["task_name"], payload["status"], payload["start_time"], payload["duration"]) + tmplTitleKey = constant.KeyNotifyTemplateTaskTimeoutTitle + tmplTextKey = constant.KeyNotifyTemplateTaskTimeoutText } - case constant.EventTaskTimeout: - title = fmt.Sprintf("任务[%v] 超时", payload["task_name"]) - text = fmt.Sprintf("任务 #%v %v\n执行超时\n执行时间: %v\n耗时: %vms", payload["task_id"], payload["task_name"], payload["start_time"], payload["duration"]) + + // 处理输出内容,避免过长 + if output, ok := payload["output"].(string); ok { + // 如果输出包含了压缩后的 Base64 (以 "base64:" 开头),由于是推送到通知,我们尽量不发大段 Base64 + // 这里简单处理:如果过长则截断,或者如果是压缩的则记录一下 + if len(output) > 1000 { + payload["output"] = output[len(output)-1000:] + "\n...(截断)" + } + } + case constant.EventSystemNotice: title, _ = payload["title"].(string) text, _ = payload["content"].(string) @@ -375,6 +440,28 @@ func (s *NotificationService) handleEvent(bindingType string) eventbus.Handler { return } + if tmplTitleKey != "" { + tmplTitle := s.settingsService.Get(constant.SectionNotify, tmplTitleKey) + tmplText := s.settingsService.Get(constant.SectionNotify, tmplTextKey) + + if tmplTitle != "" { + title = s.parseTemplate(tmplTitle, payload) + } + if tmplText != "" { + text = s.parseTemplate(tmplText, payload) + } + + // 如果模板为空,使用兜底默认逻辑(保持向上兼容) + if title == "" || text == "" { + title, text = s.getDefaultMessage(e.Type, payload) + } + } + + // 添加全局前缀 + if prefix != "" { + title = fmt.Sprintf("%s %s", prefix, title) + } + bindings := s.GetBindingsByEvent(bindingType, e.Type, dataID) if len(bindings) == 0 { return diff --git a/internal/services/settings_service.go b/internal/services/settings_service.go index 5b540bf..54a1ce3 100644 --- a/internal/services/settings_service.go +++ b/internal/services/settings_service.go @@ -87,6 +87,17 @@ func (s *SettingsService) InitSettings() error { } } + // 从 constant.DefaultSettings 初始化所有缺少的通知模板 + if notifyDefaults, ok := constant.DefaultSettings[constant.SectionNotify]; ok { + for k, v := range notifyDefaults { + var count int64 + database.DB.Model(&models.Setting{}).Where(&models.Setting{Section: constant.SectionNotify, Key: k}).Count(&count) + if count == 0 { + s.Set(constant.SectionNotify, k, v) + } + } + } + // 初始化或获取 JWT Secret 密码 var secCount int64 database.DB.Model(&models.Setting{}).Where(&models.Setting{Section: constant.SectionSecurity, Key: constant.KeySecret}).Count(&secCount) diff --git a/web/src/api/index.ts b/web/src/api/index.ts index 2b60d46..46a8c87 100644 --- a/web/src/api/index.ts +++ b/web/src/api/index.ts @@ -151,6 +151,9 @@ export const api = { getAbout: () => request('/settings/about'), getChangelog: () => request('/settings/changelog'), get: (section: string, key: string) => request(`/settings/${section}/${key}`), + getSection: (section: string) => request>(`/settings/${section}`), + setSection: (section: string, values: Record) => + request(`/settings/${section}`, { method: 'PUT', body: JSON.stringify(values) }), generateToken: (section: string, key: string) => request(`/settings/${section}/${key}/generate`, { method: 'POST' }), getLoginLogs: (params?: { page?: number; page_size?: number; username?: string }) => { diff --git a/web/src/assets/index.css b/web/src/assets/index.css index 6f4ecbb..6dccabe 100644 --- a/web/src/assets/index.css +++ b/web/src/assets/index.css @@ -222,6 +222,10 @@ text-rendering: optimizeLegibility; } + input, textarea, button, select { + font-family: 'Inter', -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif !important; + } + .font-code, .font-mono, code, diff --git a/web/src/views/notify/Notify.vue b/web/src/views/notify/Notify.vue index 9255330..cd22eac 100644 --- a/web/src/views/notify/Notify.vue +++ b/web/src/views/notify/Notify.vue @@ -17,6 +17,7 @@ import ChannelList from './components/ChannelList.vue' import EventBinding from './components/EventBinding.vue' import ApiUsage from './components/ApiUsage.vue' import ChannelDialog from './components/ChannelDialog.vue' +import TemplateSettings from './components/TemplateSettings.vue' const activeTab = ref('channels') @@ -322,10 +323,11 @@ onMounted(() => {

消息推送

配置通知渠道,绑定系统事件实现自动推送

- - 渠道管理 - 事件绑定 - 脚本调用 + + 渠道管理 + 推送模板 + 事件绑定 + 脚本调用 @@ -335,6 +337,11 @@ onMounted(() => { @delete="confirmDelete" @test="testChannel" /> + + + + + +import { ref, onMounted } from 'vue' +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card' +import { Input } from '@/components/ui/input' +import { Label } from '@/components/ui/label' +import { Button } from '@/components/ui/button' +import { Textarea } from '@/components/ui/textarea' +import { Badge } from '@/components/ui/badge' +import { api } from '@/api' +import { toast } from 'vue-sonner' +import { Save, RefreshCcw, Info, ChevronDown, ChevronUp } from 'lucide-vue-next' + +const props = defineProps<{ + activeTab?: string, +}>() + +const emit = defineEmits(['update:activeTab']) + +const loading = ref(false) +const saving = ref(false) + +const prefix = ref('') +const templates = ref>({}) +const expandedEvents = ref>({}) + +const eventGroups = [ + { + title: '系统与安全事件', + events: [ + { + id: 'user_login', + name: '用户登录 (成功/失败)', + keys: { title: 'notify_template_user_login_title', text: 'notify_template_user_login_text' }, + variables: ['username', 'ip', 'status_label', 'message'] + }, + { + id: 'brute_force_login', + name: '密码尝试破解', + keys: { title: 'notify_template_brute_force_login_title', text: 'notify_template_brute_force_login_text' }, + variables: ['ip', 'username'] + }, + { + id: 'password_changed', + name: '密码修改', + keys: { title: 'notify_template_password_changed_title', text: 'notify_template_password_changed_text' }, + variables: ['username'] + } + ] + }, + { + title: '任务执行事件', + events: [ + { + id: 'task_success', + name: '任务成功', + keys: { title: 'notify_template_task_success_title', text: 'notify_template_task_success_text' }, + variables: ['task_id', 'task_name', 'start_time', 'duration', 'output'] + }, + { + id: 'task_failed', + name: '任务失败', + keys: { title: 'notify_template_task_failed_title', text: 'notify_template_task_failed_text' }, + variables: ['task_id', 'task_name', 'start_time', 'duration', 'error', 'output'] + }, + { + id: 'task_timeout', + name: '任务超时', + keys: { title: 'notify_template_task_timeout_title', text: 'notify_template_task_timeout_text' }, + variables: ['task_id', 'task_name', 'start_time', 'duration', 'output'] + } + ] + } +] + +async function loadSettings() { + loading.value = true + try { + const res = await api.settings.getSection('notify') + prefix.value = res.notify_prefix || '[白虎面板]' + templates.value = res + } catch (e: any) { + toast.error('加载配置失败: ' + e.message) + } finally { + loading.value = false + } +} + +async function saveSettings() { + saving.value = true + try { + const data: Record = { + notify_prefix: prefix.value, + ...templates.value + } + await api.settings.setSection('notify', data) + toast.success('模板配置已保存') + } catch (e: any) { + toast.error('保存失败: ' + e.message) + } finally { + saving.value = false + } +} + +function insertVariable(eventKey: string, variable: string) { + const current = templates.value[eventKey] || '' + templates.value[eventKey] = current + ` {{${variable}}}` +} + +function toggleExpand(id: string) { + expandedEvents.value[id] = !expandedEvents.value[id] +} + +onMounted(() => { + loadSettings() + // 默认展开第一个 + expandedEvents.value['user_login'] = true +}) + + +