Files
TaskPool/internal/services/notification_service.go
T
admin e6956aa001 Initial commit: TaskPool React panel
- React frontend with route-level code splitting
- Backend rebranded from Baihu to TaskPool
- DB brand migration script and local compatibility
2026-07-26 08:43:52 +08:00

565 lines
18 KiB
Go

package services
import (
"encoding/json"
"fmt"
"sync"
"github.com/engigu/taskpool/internal/constant"
"github.com/engigu/taskpool/internal/database"
"github.com/engigu/taskpool/internal/eventbus"
"github.com/engigu/taskpool/internal/logger"
"github.com/engigu/taskpool/internal/models"
"github.com/engigu/taskpool/internal/utils"
"github.com/engigu/taskpool/internal/sdk/messenger"
"gorm.io/gorm"
"regexp"
"strings"
)
// NotifyChannel 通知渠道配置
type NotifyChannel struct {
ID string `json:"id"`
Name string `json:"name"`
Type string `json:"type"`
Enabled bool `json:"enabled"`
CreatedAt models.LocalTime `json:"created_at"`
Config map[string]string `json:"config"`
}
// NotifyMessage 通知消息
type NotifyMessage struct {
Title string `json:"title"`
Text string `json:"text"`
}
// NotifyResult 发送结果
type NotifyResult struct {
Success bool `json:"success"`
Error string `json:"error,omitempty"`
}
// SupportedChannelTypes 支持的渠道类型
var SupportedChannelTypes = []map[string]string{
{"type": messenger.ChannelTelegram, "label": "Telegram"},
{"type": messenger.ChannelBark, "label": "Bark"},
{"type": messenger.ChannelDtalk, "label": "钉钉"},
{"type": messenger.ChannelQyWeiXin, "label": "企业微信"},
{"type": messenger.ChannelFeishu, "label": "飞书"},
{"type": messenger.ChannelEmail, "label": "邮件"},
{"type": messenger.ChannelCustom, "label": "自定义Webhook"},
{"type": messenger.ChannelNtfy, "label": "Ntfy"},
{"type": messenger.ChannelGotify, "label": "Gotify"},
{"type": messenger.ChannelPushMe, "label": "PushMe"},
// {"type": messenger.ChannelWeChatOFAccount, "label": "微信公众号"},
{"type": messenger.ChannelAliyunSMS, "label": "阿里云短信"},
{"type": messenger.ChannelPushPlus, "label": "PushPlus"},
{"type": messenger.ChannelVoceChat, "label": "VoceChat"},
{"type": messenger.ChannelWxPusher, "label": "WxPusher"},
}
// SupportedEvents 支持的事件类型
var SupportedEvents = []map[string]string{
{"type": constant.EventUserLogin, "label": "用户登录", "binding_type": constant.BindingTypeSystem},
{"type": constant.EventBruteForceLogin, "label": "密码多次错误", "binding_type": constant.BindingTypeSystem},
{"type": constant.EventPasswordChanged, "label": "密码修改", "binding_type": constant.BindingTypeSystem},
{"type": constant.EventTaskSuccess, "label": "任务成功", "binding_type": constant.BindingTypeTask},
{"type": constant.EventTaskFailed, "label": "任务失败", "binding_type": constant.BindingTypeTask},
{"type": constant.EventTaskTimeout, "label": "任务超时", "binding_type": constant.BindingTypeTask},
}
type NotificationService struct {
settingsService *SettingsService
mu sync.RWMutex
}
func NewNotificationService() *NotificationService {
return &NotificationService{
settingsService: NewSettingsService(),
}
}
// GetChannels 获取所有渠道
func (s *NotificationService) GetChannels() []NotifyChannel {
s.mu.RLock()
defer s.mu.RUnlock()
return s.getChannelsInternal()
}
// SaveChannel 保存/更新渠道
func (s *NotificationService) SaveChannel(channel NotifyChannel) error {
s.mu.Lock()
defer s.mu.Unlock()
configJSON, err := json.Marshal(channel.Config)
if err != nil {
return err
}
if channel.ID == "" {
// 新建
channel.ID = utils.GenerateID()
notifyWay := &models.NotifyWay{
ID: channel.ID,
Name: channel.Name,
Type: channel.Type,
Config: models.BigText(configJSON),
Enabled: utils.BoolPtr(channel.Enabled),
}
return database.DB.Create(notifyWay).Error
}
// 更新
updates := map[string]interface{}{
"name": channel.Name,
"type": channel.Type,
"config": models.BigText(configJSON),
"enabled": &channel.Enabled,
}
return database.DB.Model(&models.NotifyWay{}).Where("id = ?", channel.ID).Updates(updates).Error
}
// DeleteChannel 删除渠道
func (s *NotificationService) DeleteChannel(id string) error {
s.mu.Lock()
defer s.mu.Unlock()
// 检查渠道是否存在
var count int64
database.DB.Model(&models.NotifyWay{}).Where("id = ?", id).Count(&count)
if count == 0 {
return fmt.Errorf("渠道 %s 不存在", id)
}
// 删除渠道
if err := database.DB.Where("id = ?", id).Delete(&models.NotifyWay{}).Error; err != nil {
return err
}
// 同时清理事件绑定中引用此渠道的配置
if err := database.DB.Where("way_id = ?", id).Delete(&models.NotifyBinding{}).Error; err != nil {
logger.Errorf("[Notify] 清理事件绑定失败: %v", err)
}
return nil
}
// GetBindings 获取事件绑定列表(新接口,用于前端展示)
func (s *NotificationService) GetBindings() []models.NotifyBinding {
var bindings []models.NotifyBinding
database.DB.Find(&bindings)
return bindings
}
// SaveBinding 保存事件绑定
func (s *NotificationService) SaveBinding(binding *models.NotifyBinding) error {
if binding.ID == "" {
// 检查是否已经存在相同的绑定(避免重复点击导致多个记录)
var existing models.NotifyBinding
res := database.DB.Where("type = ? AND event = ? AND way_id = ? AND data_id = ?",
binding.Type, binding.Event, binding.WayID, binding.DataID).Limit(1).Find(&existing)
if res.Error == nil && res.RowsAffected > 0 {
// 如果已存在且未删除,更新现有记录(特别是 Extra 字段)
existing.Extra = binding.Extra
err := database.DB.Save(&existing).Error
if err == nil {
*binding = existing
}
return err
}
binding.ID = utils.GenerateID()
return database.DB.Create(binding).Error
}
return database.DB.Save(binding).Error
}
// BatchSaveBindings 批量保存事件绑定
func (s *NotificationService) BatchSaveBindings(bindingType, dataID string, bindings []models.NotifyBinding) error {
return database.DB.Transaction(func(tx *gorm.DB) error {
// 如果指定了 dataID,先清理该对象的所有现有绑定
if dataID != "" {
if err := tx.Where("type = ? AND data_id = ?", bindingType, dataID).Delete(&models.NotifyBinding{}).Error; err != nil {
return err
}
}
// 批量插入新绑定
for i := range bindings {
bindings[i].ID = utils.GenerateID()
bindings[i].Type = bindingType
bindings[i].DataID = dataID
if err := tx.Create(&bindings[i]).Error; err != nil {
return err
}
}
return nil
})
}
// DeleteBinding 删除事件绑定
func (s *NotificationService) DeleteBinding(id string) error {
return database.DB.Where("id = ?", id).Delete(&models.NotifyBinding{}).Error
}
// GetBindingsByEvent 根据事件类型和数据ID获取绑定
func (s *NotificationService) GetBindingsByEvent(bindingType, event, dataID string) []models.NotifyBinding {
var bindings []models.NotifyBinding
// 如果是任务事件且带有 dataID,只获取特定任务的绑定(禁用全局任务配置)
if bindingType == constant.BindingTypeTask && dataID != "" {
database.DB.Where("type = ? AND event = ? AND data_id = ?", constant.BindingTypeTask, event, dataID).Find(&bindings)
return bindings
}
// 对于系统事件或其他情况
query := database.DB.Where("event = ?", event)
if bindingType != "" {
query = query.Where("type = ?", bindingType)
}
if dataID != "" {
query = query.Where("data_id = ?", dataID)
} else {
query = query.Where("data_id = ? OR data_id IS NULL", "")
}
query.Find(&bindings)
return bindings
}
// SendToChannel 使用 messenger SDK 发送通知到指定渠道
func (s *NotificationService) SendToChannel(channel NotifyChannel, msg *NotifyMessage) *NotifyResult {
result, err := messenger.Send(channel.Type, messenger.ChannelConfig(channel.Config), &messenger.Message{
Title: msg.Title,
Text: msg.Text,
})
payload := map[string]interface{}{
"title": msg.Title,
"content": msg.Text,
"channel_id": channel.ID,
"channel_name": channel.Name,
"success": false,
"error_msg": "",
}
if err != nil {
payload["error_msg"] = err.Error()
eventbus.DefaultBus.Publish(eventbus.Event{
Type: constant.EventNotifySent,
Payload: payload,
})
return &NotifyResult{Success: false, Error: err.Error()}
}
if !result.Success {
payload["error_msg"] = result.Error
eventbus.DefaultBus.Publish(eventbus.Event{
Type: constant.EventNotifySent,
Payload: payload,
})
return &NotifyResult{Success: false, Error: result.Error}
}
payload["success"] = true
eventbus.DefaultBus.Publish(eventbus.Event{
Type: constant.EventNotifySent,
Payload: payload,
})
return &NotifyResult{Success: true}
}
// SendByChannelID 根据渠道ID发送通知
func (s *NotificationService) SendByChannelID(channelID string, msg *NotifyMessage) *NotifyResult {
s.mu.RLock()
defer s.mu.RUnlock()
var notifyWay models.NotifyWay
res := database.DB.Where("id = ?", channelID).Limit(1).Find(&notifyWay)
if res.Error != nil || res.RowsAffected == 0 {
return &NotifyResult{Success: false, Error: "渠道不存在"}
}
if !utils.DerefBool(notifyWay.Enabled, true) {
return &NotifyResult{Success: false, Error: "渠道已禁用"}
}
var config map[string]string
if err := json.Unmarshal([]byte(notifyWay.Config), &config); err != nil {
return &NotifyResult{Success: false, Error: "渠道配置解析失败"}
}
ch := NotifyChannel{
ID: notifyWay.ID,
Name: notifyWay.Name,
Type: notifyWay.Type,
Enabled: utils.DerefBool(notifyWay.Enabled, true),
Config: config,
}
return s.SendToChannel(ch, msg)
}
// SubscribeEvents 注册通知服务自身为事件流的订阅者
func (s *NotificationService) SubscribeEvents(bus *eventbus.EventBus) {
// 系统事件
systemEvents := []string{constant.EventUserLogin, constant.EventBruteForceLogin, constant.EventPasswordChanged}
for _, evt := range systemEvents {
bus.Subscribe(evt, s.handleEvent(constant.BindingTypeSystem))
}
// 任务事件
taskEvents := []string{constant.EventTaskSuccess, constant.EventTaskFailed, constant.EventTaskTimeout}
for _, evt := range taskEvents {
bus.Subscribe(evt, s.handleEvent(constant.BindingTypeTask))
}
// 通用系统通知
bus.Subscribe(constant.EventSystemNotice, s.handleEvent(constant.BindingTypeSystem))
}
var ansiRegexp = regexp.MustCompile(`[\x1b\x9b][\[()#;?]*([0-9]{1,4}(;[0-9]{0,4})*)?[0-9A-ORZcf-nqry=><]`)
// stripAnsi 移除字符串中的 ANSI 转义码(如颜色代码)
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
}
// resolveEvent 解析不同事件类型,返回对应的模板Key、静态内容(非模板事件)和原始任务输出
func (s *NotificationService) resolveEvent(eventType string, payload map[string]interface{}) (tmplTitleKey, tmplTextKey, title, text, rawOutput string, ok bool) {
switch eventType {
case constant.EventUserLogin:
tmplTitleKey = constant.KeyNotifyTemplateUserLoginTitle
tmplTextKey = constant.KeyNotifyTemplateUserLoginText
// 特殊处理登录状态
status, _ := payload["status"].(string)
if status == "success" {
payload["status_label"] = "成功"
} else {
payload["status_label"] = "失败"
}
case constant.EventBruteForceLogin:
tmplTitleKey = constant.KeyNotifyTemplateBruteForceLoginTitle
tmplTextKey = constant.KeyNotifyTemplateBruteForceLoginText
case constant.EventPasswordChanged:
tmplTitleKey = constant.KeyNotifyTemplatePasswordChangedTitle
tmplTextKey = constant.KeyNotifyTemplatePasswordChangedText
case constant.EventTaskSuccess, constant.EventTaskFailed, constant.EventTaskTimeout:
switch eventType {
case constant.EventTaskSuccess:
tmplTitleKey = constant.KeyNotifyTemplateTaskSuccessTitle
tmplTextKey = constant.KeyNotifyTemplateTaskSuccessText
case constant.EventTaskFailed:
tmplTitleKey = constant.KeyNotifyTemplateTaskFailedTitle
tmplTextKey = constant.KeyNotifyTemplateTaskFailedText
case constant.EventTaskTimeout:
tmplTitleKey = constant.KeyNotifyTemplateTaskTimeoutTitle
tmplTextKey = constant.KeyNotifyTemplateTaskTimeoutText
}
// 处理输出内容,避免过长
if output, ok := payload["output"].(string); ok {
rawOutput = output
// trimmed := utils.TrimLastRunes(output, 1000)
// if len(trimmed) < len(output) {
// payload["output"] = trimmed + "\n...(截断)"
// }
}
case constant.EventSystemNotice:
title, _ = payload["title"].(string)
text, _ = payload["content"].(string)
default:
return "", "", "", "", "", false
}
return tmplTitleKey, tmplTextKey, title, text, rawOutput, true
}
// buildMessage 匹配并解析模板内容,提供兜底消息并拼接全局前缀
func (s *NotificationService) buildMessage(eventType string, tmplTitleKey, tmplTextKey, defaultTitle, defaultText, prefix string, payload map[string]interface{}) (title, text string) {
title = defaultTitle
text = defaultText
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(eventType, payload)
}
}
// 添加全局前缀
if prefix != "" && title != "" {
title = fmt.Sprintf("%s %s", prefix, title)
}
return title, text
}
// handleEvent 处理事件订阅并发送通知
func (s *NotificationService) handleEvent(bindingType string) eventbus.Handler {
return func(e eventbus.Event) {
payload, ok := e.Payload.(map[string]interface{})
if !ok {
return
}
var dataID string
if id, ok := payload["task_id"].(string); ok {
dataID = id
}
// 获取全局前缀并解析事件数据
prefix := s.settingsService.Get(constant.SectionNotify, constant.KeyNotifyPrefix)
tmplTitleKey, tmplTextKey, title, text, rawOutput, ok := s.resolveEvent(e.Type, payload)
if !ok {
return
}
// 构建最终的通知标题和正文文本
title, text = s.buildMessage(e.Type, tmplTitleKey, tmplTextKey, title, text, prefix, payload)
bindings := s.GetBindingsByEvent(bindingType, e.Type, dataID)
if len(bindings) == 0 {
return
}
var cleanLog string
if rawOutput != "" {
// cleanLog = stripAnsi(rawOutput)
cleanLog = rawOutput
}
channels := s.GetChannels()
channelMap := make(map[string]NotifyChannel)
for _, ch := range channels {
channelMap[ch.ID] = ch
}
for _, binding := range bindings {
ch, ok := channelMap[binding.WayID]
if !ok || !ch.Enabled {
continue
}
// 克隆文本以便修改
currentText := text
// 解析额外配置
var extra models.BindingExtra
if binding.Extra != "" {
_ = json.Unmarshal([]byte(binding.Extra), &extra)
}
// 默认日志限制为 1000
if extra.LogLimit <= 0 {
extra.LogLimit = 1000
}
// 如果开启了日志推送
if extra.EnableLog {
if cleanLog != "" {
trimmed := utils.TrimLastRunes(cleanLog, extra.LogLimit)
if len(trimmed) < len(cleanLog) {
trimmed = "...\n" + trimmed
}
currentText += "\n\n[执行日志]\n" + trimmed
}
}
go func(channel NotifyChannel, msgTitle, msgText string) {
result := s.SendToChannel(channel, &NotifyMessage{Title: msgTitle, Text: msgText})
if !result.Success {
logger.Warnf("[Notify] 发送事件 %s 到渠道 %s(%s) 失败: %s", e.Type, channel.Name, channel.Type, result.Error)
}
}(ch, title, currentText)
}
}
}
// --- 内部方法 ---
// getChannelsInternal 从 notify_ways 表中读取所有渠道配置
func (s *NotificationService) getChannelsInternal() []NotifyChannel {
var notifyWays []models.NotifyWay
database.DB.Find(&notifyWays)
channels := make([]NotifyChannel, 0, len(notifyWays))
for _, nw := range notifyWays {
var config map[string]string
if err := json.Unmarshal([]byte(nw.Config), &config); err != nil {
logger.Warnf("[Notify] 解析渠道 %s 配置失败: %v", nw.ID, err)
continue
}
channels = append(channels, NotifyChannel{
ID: nw.ID,
Name: nw.Name,
Type: nw.Type,
Enabled: utils.DerefBool(nw.Enabled, true),
CreatedAt: nw.CreatedAt,
Config: config,
})
}
return channels
}