From 0639f5ec6d7425b48b133b65747e34c28039ba98 Mon Sep 17 00:00:00 2001 From: engigu Date: Mon, 17 Nov 2025 22:33:59 +0800 Subject: [PATCH 01/24] feat: send message rewrite --- .../send_message_service/message_handler.go | 164 ++++++++++++++++++ service/send_message_service/send_message.go | 60 +------ 2 files changed, 173 insertions(+), 51 deletions(-) create mode 100644 service/send_message_service/message_handler.go diff --git a/service/send_message_service/message_handler.go b/service/send_message_service/message_handler.go new file mode 100644 index 0000000..f74247a --- /dev/null +++ b/service/send_message_service/message_handler.go @@ -0,0 +1,164 @@ +package send_message_service + +import ( + "message-nest/models" + "message-nest/service/send_way_service" +) + +// 消息类型常量定义 +const ( + MessageTypeEmail = "Email" + MessageTypeDtalk = "Dtalk" + MessageTypeQyWeiXin = "QyWeiXin" + MessageTypeCustom = "Custom" + MessageTypeWeChatOFAccount = "WeChatOFAccount" + MessageTypeMessageNest = "MessageNest" +) + +// MessageHandler 消息处理器接口 +type MessageHandler interface { + // Send 发送消息 + // 返回:响应内容,错误信息 + Send(msgObj interface{}, ins models.SendTasksIns, typeC string, title string, content string, url string) (string, string) + + // GetType 返回该处理器支持的消息类型字符串 + GetType() string +} + +// MessageHandlerRegistry 消息处理器注册表 +type MessageHandlerRegistry struct { + handlers map[string]MessageHandler +} + +// NewMessageHandlerRegistry 创建新的消息处理器注册表 +func NewMessageHandlerRegistry() *MessageHandlerRegistry { + return &MessageHandlerRegistry{ + handlers: make(map[string]MessageHandler), + } +} + +// Register 注册消息处理器 +func (r *MessageHandlerRegistry) Register(handler MessageHandler) { + r.handlers[handler.GetType()] = handler +} + +// GetHandler 根据类型字符串获取对应的处理器 +func (r *MessageHandlerRegistry) GetHandler(wayType string) (MessageHandler, bool) { + handler, ok := r.handlers[wayType] + return handler, ok +} + +// 全局消息处理器注册表 +var globalRegistry = NewMessageHandlerRegistry() + +// init 初始化时注册所有处理器 +func init() { + globalRegistry.Register(&EmailHandler{}) + globalRegistry.Register(&DtalkHandler{}) + globalRegistry.Register(&QyWeiXinHandler{}) + globalRegistry.Register(&CustomHandler{}) + globalRegistry.Register(&WeChatOfAccountHandler{}) + globalRegistry.Register(&HostMessageHandler{}) +} + +// GetGlobalRegistry 获取全局注册表 +func GetGlobalRegistry() *MessageHandlerRegistry { + return globalRegistry +} + +// EmailHandler 邮箱消息处理器 +type EmailHandler struct{} + +func (h *EmailHandler) GetType() string { + return MessageTypeEmail +} + +func (h *EmailHandler) Send(msgObj interface{}, ins models.SendTasksIns, typeC string, title string, content string, url string) (string, string) { + auth, ok := msgObj.(send_way_service.WayDetailEmail) + if !ok { + return "", "类型转换失败" + } + es := EmailService{} + errMsg := es.SendTaskEmail(auth, ins, typeC, title, content) + return "", errMsg +} + +// DtalkHandler 钉钉消息处理器 +type DtalkHandler struct{} + +func (h *DtalkHandler) GetType() string { + return MessageTypeDtalk +} + +func (h *DtalkHandler) Send(msgObj interface{}, ins models.SendTasksIns, typeC string, title string, content string, url string) (string, string) { + auth, ok := msgObj.(send_way_service.WayDetailDTalk) + if !ok { + return "", "类型转换失败" + } + es := DtalkService{} + return es.SendDtalkMessage(auth, ins, typeC, title, content) +} + +// QyWeiXinHandler 企业微信消息处理器 +type QyWeiXinHandler struct{} + +func (h *QyWeiXinHandler) GetType() string { + return MessageTypeQyWeiXin +} + +func (h *QyWeiXinHandler) Send(msgObj interface{}, ins models.SendTasksIns, typeC string, title string, content string, url string) (string, string) { + auth, ok := msgObj.(send_way_service.WayDetailQyWeiXin) + if !ok { + return "", "类型转换失败" + } + es := QyWeiXinService{} + return es.SendQyWeiXinMessage(auth, ins, typeC, title, content) +} + +// CustomHandler 自定义webhook消息处理器 +type CustomHandler struct{} + +func (h *CustomHandler) GetType() string { + return MessageTypeCustom +} + +func (h *CustomHandler) Send(msgObj interface{}, ins models.SendTasksIns, typeC string, title string, content string, url string) (string, string) { + auth, ok := msgObj.(send_way_service.WayDetailCustom) + if !ok { + return "", "类型转换失败" + } + cs := CustomService{} + return cs.SendCustomMessage(auth, ins, typeC, title, content) +} + +// WeChatOfAccountHandler 微信公众号消息处理器 +type WeChatOfAccountHandler struct{} + +func (h *WeChatOfAccountHandler) GetType() string { + return MessageTypeWeChatOFAccount +} + +func (h *WeChatOfAccountHandler) Send(msgObj interface{}, ins models.SendTasksIns, typeC string, title string, content string, url string) (string, string) { + auth, ok := msgObj.(send_way_service.WeChatOFAccount) + if !ok { + return "", "类型转换失败" + } + cs := WeChatOfAccountService{} + return cs.SendWeChatOfAccountMessage(auth, ins, typeC, title, content, url) +} + +// HostMessageHandler 托管消息处理器 +type HostMessageHandler struct{} + +func (h *HostMessageHandler) GetType() string { + return MessageTypeMessageNest +} + +func (h *HostMessageHandler) Send(msgObj interface{}, ins models.SendTasksIns, typeC string, title string, content string, url string) (string, string) { + auth, ok := msgObj.(send_way_service.MessageNest) + if !ok { + return "", "类型转换失败" + } + cs := HostMessageService{} + return cs.SendHostMessage(auth, ins, typeC, title, content) +} diff --git a/service/send_message_service/send_message.go b/service/send_message_service/send_message.go index 025df9d..9981125 100644 --- a/service/send_message_service/send_message.go +++ b/service/send_message_service/send_message.go @@ -151,61 +151,19 @@ func (sm *SendMessageService) Send(task models.TaskIns) (string, error) { continue } - // 邮箱类型的实例发送 - emailAuth, ok := msgObj.(send_way_service.WayDetailEmail) - if ok { - //continue - es := EmailService{} - errMsg := es.SendTaskEmail(emailAuth, ins.SendTasksIns, typeC, sm.Title, content) - sm.LogsAndStatusMark(sm.TransError(errMsg), errStrIsSuccess(errMsg)) + // 使用注册的处理器发送消息 + registry := GetGlobalRegistry() + handler, ok := registry.GetHandler(way.Type) + if !ok { + sm.LogsAndStatusMark(fmt.Sprintf("发送失败:未知渠道类型 %s 的发信实例: %s\n", way.Type, ins.ID), SendFail) continue } - // 钉钉类型的实例发送 - dtalkAuth, ok := msgObj.(send_way_service.WayDetailDTalk) - if ok { - es := DtalkService{} - res, errMsg := es.SendDtalkMessage(dtalkAuth, ins.SendTasksIns, typeC, sm.Title, content) + + res, errMsg := handler.Send(msgObj, ins.SendTasksIns, typeC, sm.Title, content, sm.URL) + if res != "" { sm.LogsAndStatusMark(fmt.Sprintf("返回内容:%s", res), sm.Status) - sm.LogsAndStatusMark(sm.TransError(errMsg), errStrIsSuccess(errMsg)) - continue } - // 企业微信类型的实例发送 - qywxAuth, ok := msgObj.(send_way_service.WayDetailQyWeiXin) - if ok { - es := QyWeiXinService{} - res, errMsg := es.SendQyWeiXinMessage(qywxAuth, ins.SendTasksIns, typeC, sm.Title, content) - sm.LogsAndStatusMark(fmt.Sprintf("返回内容:%s", res), sm.Status) - sm.LogsAndStatusMark(sm.TransError(errMsg), errStrIsSuccess(errMsg)) - continue - } - // 自定义webhook类型的实例发送 - customAuth, ok := msgObj.(send_way_service.WayDetailCustom) - if ok { - cs := CustomService{} - res, errMsg := cs.SendCustomMessage(customAuth, ins.SendTasksIns, typeC, sm.Title, content) - sm.LogsAndStatusMark(fmt.Sprintf("返回内容:%s", res), sm.Status) - sm.LogsAndStatusMark(sm.TransError(errMsg), errStrIsSuccess(errMsg)) - continue - } - // 微信公众号模板消息的实例发送 - wca, ok := msgObj.(send_way_service.WeChatOFAccount) - if ok { - cs := WeChatOfAccountService{} - res, errMsg := cs.SendWeChatOfAccountMessage(wca, ins.SendTasksIns, typeC, sm.Title, content, sm.URL) - sm.LogsAndStatusMark(fmt.Sprintf("返回内容:%s", res), sm.Status) - sm.LogsAndStatusMark(sm.TransError(errMsg), errStrIsSuccess(errMsg)) - continue - } - // 托管消息的实例发送 - mnt, ok := msgObj.(send_way_service.MessageNest) - if ok { - cs := HostMessageService{} - res, errMsg := cs.SendHostMessage(mnt, ins.SendTasksIns, typeC, sm.Title, content) - sm.LogsAndStatusMark(fmt.Sprintf("返回内容:%s", res), sm.Status) - sm.LogsAndStatusMark(sm.TransError(errMsg), errStrIsSuccess(errMsg)) - continue - } - sm.LogsAndStatusMark(fmt.Sprintf("发送失败:未知渠道的发信实例: %s\n", ins.ID), SendFail) + sm.LogsAndStatusMark(sm.TransError(errMsg), errStrIsSuccess(errMsg)) } From ff194daef28ae13e5992a6a573dce0c3984c7cdd Mon Sep 17 00:00:00 2001 From: engigu Date: Thu, 4 Dec 2025 21:16:32 +0800 Subject: [PATCH 02/24] feat: send recode adjust --- pkg/message/dtalk.go | 40 +++++ pkg/message/qyweixin.go | 39 ++++- routers/api/v1/send_message.go | 24 ++- .../send_message_service/message_handler.go | 164 ------------------ service/send_message_service/send_custom.go | 29 ---- service/send_message_service/send_dtalk.go | 47 ----- service/send_message_service/send_email.go | 37 ---- .../send_hosted_message.go | 36 ---- service/send_message_service/send_message.go | 42 +++-- service/send_message_service/send_qywexin.go | 46 ----- .../send_wechat_account.go | 42 ----- .../unified/channel_registry.go | 126 ++++++++++++++ .../unified/channels/base.go | 45 +++++ .../unified/channels/custom.go | 83 +++++++++ .../unified/channels/dtalk.go | 56 ++++++ .../unified/channels/email.go | 46 +++++ .../unified/channels/nest.go | 38 ++++ .../unified/channels/qyweixin.go | 58 +++++++ .../unified/channels/types.go | 53 ++++++ .../unified/channels/wechat.go | 48 +++++ .../pages/sendTasks/ApiCodeViewer.vue | 58 ++++++- web/src/util/viewApi.js | 10 ++ 22 files changed, 743 insertions(+), 424 deletions(-) delete mode 100644 service/send_message_service/message_handler.go delete mode 100644 service/send_message_service/send_custom.go delete mode 100644 service/send_message_service/send_dtalk.go delete mode 100644 service/send_message_service/send_email.go delete mode 100644 service/send_message_service/send_hosted_message.go delete mode 100644 service/send_message_service/send_qywexin.go delete mode 100644 service/send_message_service/send_wechat_account.go create mode 100644 service/send_message_service/unified/channel_registry.go create mode 100644 service/send_message_service/unified/channels/base.go create mode 100644 service/send_message_service/unified/channels/custom.go create mode 100644 service/send_message_service/unified/channels/dtalk.go create mode 100644 service/send_message_service/unified/channels/email.go create mode 100644 service/send_message_service/unified/channels/nest.go create mode 100644 service/send_message_service/unified/channels/qyweixin.go create mode 100644 service/send_message_service/unified/channels/types.go create mode 100644 service/send_message_service/unified/channels/wechat.go diff --git a/pkg/message/dtalk.go b/pkg/message/dtalk.go index fd0e0cf..d63d05b 100644 --- a/pkg/message/dtalk.go +++ b/pkg/message/dtalk.go @@ -62,6 +62,26 @@ func (t *Dtalk) SendMessageText(text string, at ...string) ([]byte, error) { "content": text, }, } + + // 添加@功能 + if len(at) > 0 { + atMobiles := []string{} + isAtAll := false + + for _, mobile := range at { + if mobile == "all" || mobile == "@all" { + isAtAll = true + } else { + atMobiles = append(atMobiles, mobile) + } + } + + msg["at"] = map[string]interface{}{ + "atMobiles": atMobiles, + "isAtAll": isAtAll, + } + } + resp, err := t.Request(msg) return resp, err } @@ -74,6 +94,26 @@ func (t *Dtalk) SendMessageMarkdown(title, text string, at ...string) ([]byte, e "text": text, }, } + + // 添加@功能 + if len(at) > 0 { + atMobiles := []string{} + isAtAll := false + + for _, mobile := range at { + if mobile == "all" || mobile == "@all" { + isAtAll = true + } else { + atMobiles = append(atMobiles, mobile) + } + } + + msg["at"] = map[string]interface{}{ + "atMobiles": atMobiles, + "isAtAll": isAtAll, + } + } + resp, err := t.Request(msg) return resp, err } diff --git a/pkg/message/qyweixin.go b/pkg/message/qyweixin.go index d2a4968..5e62152 100644 --- a/pkg/message/qyweixin.go +++ b/pkg/message/qyweixin.go @@ -53,10 +53,40 @@ func (t *QyWeiXin) Request(msg interface{}) ([]byte, error) { func (t *QyWeiXin) SendMessageText(text string, at ...string) ([]byte, error) { msg := map[string]interface{}{ "msgtype": "text", - "text": map[string]string{ + "text": map[string]interface{}{ "content": text, }, } + + // 添加@功能 + // 企业微信支持两种@方式: + // 1. mentioned_list: userid列表或"@all" + // 2. mentioned_mobile_list: 手机号列表 + if len(at) > 0 { + mentionedList := []string{} + mentionedMobileList := []string{} + + for _, item := range at { + if item == "@all" || item == "all" { + mentionedList = append(mentionedList, "@all") + } else if len(item) == 11 && item[0] == '1' { + // 判断是否为手机号(简单判断:11位且以1开头) + mentionedMobileList = append(mentionedMobileList, item) + } else { + // 否则当作userid处理 + mentionedList = append(mentionedList, item) + } + } + + textContent := msg["text"].(map[string]interface{}) + if len(mentionedList) > 0 { + textContent["mentioned_list"] = mentionedList + } + if len(mentionedMobileList) > 0 { + textContent["mentioned_mobile_list"] = mentionedMobileList + } + } + resp, err := t.Request(msg) return resp, err } @@ -64,11 +94,14 @@ func (t *QyWeiXin) SendMessageText(text string, at ...string) ([]byte, error) { func (t *QyWeiXin) SendMessageMarkdown(title, text string, at ...string) ([]byte, error) { msg := map[string]interface{}{ "msgtype": "markdown", - "markdown": map[string]string{ - "title": title, + "markdown": map[string]interface{}{ "content": text, }, } + + // 企业微信Markdown消息不支持@功能,但可以在内容中手动添加 + // 如果需要@功能,建议使用text类型 + resp, err := t.Request(msg) return resp, err } diff --git a/routers/api/v1/send_message.go b/routers/api/v1/send_message.go index 99736b5..d4e3ee7 100644 --- a/routers/api/v1/send_message.go +++ b/routers/api/v1/send_message.go @@ -23,6 +23,11 @@ type SendMessageReq struct { URL string `json:"url" label:"消息详情url地址"` MarkDown string `json:"markdown" label:"markdown内容"` Mode string `json:"mode" label:"是否异步发送"` + + // @提及相关参数 + AtMobiles []string `json:"at_mobiles" label:"@的手机号列表"` + AtUserIds []string `json:"at_user_ids" label:"@的用户ID列表"` + AtAll bool `json:"at_all" label:"是否@所有人"` } // DoSendMassage 外部调用发信接口 @@ -56,15 +61,18 @@ func DoSendMassage(c *gin.Context) { } msgService := send_message_service.SendMessageService{ - TaskID: taskID, - Title: req.Title, - Text: req.Text, - HTML: req.HTML, - URL: req.URL, - MarkDown: req.MarkDown, - CallerIp: c.ClientIP(), + TaskID: taskID, + Title: req.Title, + Text: req.Text, + HTML: req.HTML, + URL: req.URL, + MarkDown: req.MarkDown, + CallerIp: c.ClientIP(), + AtMobiles: req.AtMobiles, + AtUserIds: req.AtUserIds, + AtAll: req.AtAll, DefaultLogger: logrus.WithFields(logrus.Fields{ - //"prefix": "[Message Instance]", + "prefix": "[Send Instance]", }), } task, err := msgService.SendPreCheck() diff --git a/service/send_message_service/message_handler.go b/service/send_message_service/message_handler.go deleted file mode 100644 index f74247a..0000000 --- a/service/send_message_service/message_handler.go +++ /dev/null @@ -1,164 +0,0 @@ -package send_message_service - -import ( - "message-nest/models" - "message-nest/service/send_way_service" -) - -// 消息类型常量定义 -const ( - MessageTypeEmail = "Email" - MessageTypeDtalk = "Dtalk" - MessageTypeQyWeiXin = "QyWeiXin" - MessageTypeCustom = "Custom" - MessageTypeWeChatOFAccount = "WeChatOFAccount" - MessageTypeMessageNest = "MessageNest" -) - -// MessageHandler 消息处理器接口 -type MessageHandler interface { - // Send 发送消息 - // 返回:响应内容,错误信息 - Send(msgObj interface{}, ins models.SendTasksIns, typeC string, title string, content string, url string) (string, string) - - // GetType 返回该处理器支持的消息类型字符串 - GetType() string -} - -// MessageHandlerRegistry 消息处理器注册表 -type MessageHandlerRegistry struct { - handlers map[string]MessageHandler -} - -// NewMessageHandlerRegistry 创建新的消息处理器注册表 -func NewMessageHandlerRegistry() *MessageHandlerRegistry { - return &MessageHandlerRegistry{ - handlers: make(map[string]MessageHandler), - } -} - -// Register 注册消息处理器 -func (r *MessageHandlerRegistry) Register(handler MessageHandler) { - r.handlers[handler.GetType()] = handler -} - -// GetHandler 根据类型字符串获取对应的处理器 -func (r *MessageHandlerRegistry) GetHandler(wayType string) (MessageHandler, bool) { - handler, ok := r.handlers[wayType] - return handler, ok -} - -// 全局消息处理器注册表 -var globalRegistry = NewMessageHandlerRegistry() - -// init 初始化时注册所有处理器 -func init() { - globalRegistry.Register(&EmailHandler{}) - globalRegistry.Register(&DtalkHandler{}) - globalRegistry.Register(&QyWeiXinHandler{}) - globalRegistry.Register(&CustomHandler{}) - globalRegistry.Register(&WeChatOfAccountHandler{}) - globalRegistry.Register(&HostMessageHandler{}) -} - -// GetGlobalRegistry 获取全局注册表 -func GetGlobalRegistry() *MessageHandlerRegistry { - return globalRegistry -} - -// EmailHandler 邮箱消息处理器 -type EmailHandler struct{} - -func (h *EmailHandler) GetType() string { - return MessageTypeEmail -} - -func (h *EmailHandler) Send(msgObj interface{}, ins models.SendTasksIns, typeC string, title string, content string, url string) (string, string) { - auth, ok := msgObj.(send_way_service.WayDetailEmail) - if !ok { - return "", "类型转换失败" - } - es := EmailService{} - errMsg := es.SendTaskEmail(auth, ins, typeC, title, content) - return "", errMsg -} - -// DtalkHandler 钉钉消息处理器 -type DtalkHandler struct{} - -func (h *DtalkHandler) GetType() string { - return MessageTypeDtalk -} - -func (h *DtalkHandler) Send(msgObj interface{}, ins models.SendTasksIns, typeC string, title string, content string, url string) (string, string) { - auth, ok := msgObj.(send_way_service.WayDetailDTalk) - if !ok { - return "", "类型转换失败" - } - es := DtalkService{} - return es.SendDtalkMessage(auth, ins, typeC, title, content) -} - -// QyWeiXinHandler 企业微信消息处理器 -type QyWeiXinHandler struct{} - -func (h *QyWeiXinHandler) GetType() string { - return MessageTypeQyWeiXin -} - -func (h *QyWeiXinHandler) Send(msgObj interface{}, ins models.SendTasksIns, typeC string, title string, content string, url string) (string, string) { - auth, ok := msgObj.(send_way_service.WayDetailQyWeiXin) - if !ok { - return "", "类型转换失败" - } - es := QyWeiXinService{} - return es.SendQyWeiXinMessage(auth, ins, typeC, title, content) -} - -// CustomHandler 自定义webhook消息处理器 -type CustomHandler struct{} - -func (h *CustomHandler) GetType() string { - return MessageTypeCustom -} - -func (h *CustomHandler) Send(msgObj interface{}, ins models.SendTasksIns, typeC string, title string, content string, url string) (string, string) { - auth, ok := msgObj.(send_way_service.WayDetailCustom) - if !ok { - return "", "类型转换失败" - } - cs := CustomService{} - return cs.SendCustomMessage(auth, ins, typeC, title, content) -} - -// WeChatOfAccountHandler 微信公众号消息处理器 -type WeChatOfAccountHandler struct{} - -func (h *WeChatOfAccountHandler) GetType() string { - return MessageTypeWeChatOFAccount -} - -func (h *WeChatOfAccountHandler) Send(msgObj interface{}, ins models.SendTasksIns, typeC string, title string, content string, url string) (string, string) { - auth, ok := msgObj.(send_way_service.WeChatOFAccount) - if !ok { - return "", "类型转换失败" - } - cs := WeChatOfAccountService{} - return cs.SendWeChatOfAccountMessage(auth, ins, typeC, title, content, url) -} - -// HostMessageHandler 托管消息处理器 -type HostMessageHandler struct{} - -func (h *HostMessageHandler) GetType() string { - return MessageTypeMessageNest -} - -func (h *HostMessageHandler) Send(msgObj interface{}, ins models.SendTasksIns, typeC string, title string, content string, url string) (string, string) { - auth, ok := msgObj.(send_way_service.MessageNest) - if !ok { - return "", "类型转换失败" - } - cs := HostMessageService{} - return cs.SendHostMessage(auth, ins, typeC, title, content) -} diff --git a/service/send_message_service/send_custom.go b/service/send_message_service/send_custom.go deleted file mode 100644 index 228ba28..0000000 --- a/service/send_message_service/send_custom.go +++ /dev/null @@ -1,29 +0,0 @@ -package send_message_service - -import ( - "encoding/json" - "fmt" - - "message-nest/models" - "message-nest/pkg/message" - "message-nest/service/send_way_service" - "strings" -) - -type CustomService struct { -} - -// SendCustomMessage 执行发送钉钉 -func (s *CustomService) SendCustomMessage(auth send_way_service.WayDetailCustom, ins models.SendTasksIns, typeC string, title string, content string) (string, string) { - errMsg := "" - cli := message.CustomWebhook{} - data, _ := json.Marshal(content) - dataStr := string(data) - dataStr = strings.Trim(dataStr, "\"") - bodyStr := strings.Replace(auth.Body, "TEXT", dataStr, -1) - res, err := cli.Request(auth.Webhook, bodyStr) - if err != nil { - errMsg = fmt.Sprintf("发送失败:%s", err) - } - return string(res), errMsg -} diff --git a/service/send_message_service/send_dtalk.go b/service/send_message_service/send_dtalk.go deleted file mode 100644 index 0a8ca39..0000000 --- a/service/send_message_service/send_dtalk.go +++ /dev/null @@ -1,47 +0,0 @@ -package send_message_service - -import ( - "fmt" - "message-nest/models" - "message-nest/pkg/message" - "message-nest/service/send_ins_service" - "message-nest/service/send_way_service" -) - -type DtalkService struct { -} - -// SendDtalkMessage 执行发送钉钉 -func (s *DtalkService) SendDtalkMessage(auth send_way_service.WayDetailDTalk, ins models.SendTasksIns, typeC string, title string, content string) (string, string) { - insService := send_ins_service.SendTaskInsService{} - errStr, c := insService.ValidateDiffIns(ins) - if errStr != "" { - return errStr, "" - } - _, ok := c.(models.InsDtalkConfig) - if !ok { - return "钉钉config校验失败", "" - } - - errMsg := "" - var res []byte - var err error - cli := message.Dtalk{ - AccessToken: auth.AccessToken, - Secret: auth.Secret, - } - if typeC == "text" { - res, err = cli.SendMessageText(content) - if err != nil { - errMsg = fmt.Sprintf("发送失败:%s", ins.ContentType) - } - } else if typeC == "markdown" { - res, err = cli.SendMessageMarkdown(title, content) - if err != nil { - errMsg = fmt.Sprintf("发送失败:%s", ins.ContentType) - } - } else { - errMsg = fmt.Sprintf("未知的钉钉发送内容类型:%s", ins.ContentType) - } - return string(res), errMsg -} diff --git a/service/send_message_service/send_email.go b/service/send_message_service/send_email.go deleted file mode 100644 index 829b7b5..0000000 --- a/service/send_message_service/send_email.go +++ /dev/null @@ -1,37 +0,0 @@ -package send_message_service - -import ( - "fmt" - "message-nest/models" - "message-nest/pkg/message" - "message-nest/service/send_ins_service" - "message-nest/service/send_way_service" -) - -type EmailService struct { -} - -// SendTaskEmail 执行发送邮件 -func (s *EmailService) SendTaskEmail(auth send_way_service.WayDetailEmail, ins models.SendTasksIns, typeC string, title string, content string) string { - insService := send_ins_service.SendTaskInsService{} - errStr, c := insService.ValidateDiffIns(ins) - if errStr != "" { - return errStr - } - config, ok := c.(models.InsEmailConfig) - if !ok { - return "邮箱config校验失败" - } - - var emailer message.EmailMessage - errMsg := "" - emailer.Init(auth.Server, auth.Port, auth.Account, auth.Passwd) - if typeC == "text" { - errMsg = emailer.SendTextMessage(config.ToAccount, title, content) - } else if typeC == "html" { - errMsg = emailer.SendHtmlMessage(config.ToAccount, title, content) - } else { - errMsg = fmt.Sprintf("未知的邮件发送内容类型:%s", ins.ContentType) - } - return errMsg -} diff --git a/service/send_message_service/send_hosted_message.go b/service/send_message_service/send_hosted_message.go deleted file mode 100644 index 7c670ae..0000000 --- a/service/send_message_service/send_hosted_message.go +++ /dev/null @@ -1,36 +0,0 @@ -package send_message_service - -import ( - "message-nest/models" - "message-nest/service/hosted_message_service" - "message-nest/service/send_way_service" -) - -type HostMessageService struct { -} - -// SendHostMessage 执行托管消息记录 -func (s *HostMessageService) SendHostMessage( - auth send_way_service.MessageNest, - ins models.SendTasksIns, - typeC string, - title string, - content string) (string, string) { - - errMsg := "" - var res string - var err error - messageService := hosted_message_service.HostMessageService{ - Title: title, - Content: content, - Type: typeC, - } - err = messageService.Add() - if err != nil { - errMsg = err.Error() - res = "托管消息创建失败!" - } else { - res = "托管消息创建成功!" - } - return string(res), errMsg -} diff --git a/service/send_message_service/send_message.go b/service/send_message_service/send_message.go index 9981125..1874ff6 100644 --- a/service/send_message_service/send_message.go +++ b/service/send_message_service/send_message.go @@ -5,6 +5,7 @@ import ( "fmt" "message-nest/models" "message-nest/pkg/constant" + "message-nest/service/send_message_service/unified" "message-nest/service/send_task_service" "message-nest/service/send_way_service" "strings" @@ -33,6 +34,11 @@ type SendMessageService struct { MarkDown string CallerIp string + // @提及相关字段 + AtMobiles []string + AtUserIds []string + AtAll bool + Status int LogOutput []string @@ -151,19 +157,33 @@ func (sm *SendMessageService) Send(task models.TaskIns) (string, error) { continue } - // 使用注册的处理器发送消息 - registry := GetGlobalRegistry() - handler, ok := registry.GetHandler(way.Type) + // 使用新的Channel架构发送消息 + channelRegistry := unified.GetGlobalChannelRegistry() + channel, ok := channelRegistry.GetChannel(way.Type) if !ok { sm.LogsAndStatusMark(fmt.Sprintf("发送失败:未知渠道类型 %s 的发信实例: %s\n", way.Type, ins.ID), SendFail) continue } - res, errMsg := handler.Send(msgObj, ins.SendTasksIns, typeC, sm.Title, content, sm.URL) + // 构建统一消息内容(支持@功能) + unifiedContent := &unified.UnifiedMessageContent{ + Title: sm.Title, + Text: sm.Text, + HTML: sm.HTML, + Markdown: sm.MarkDown, + URL: sm.URL, + AtMobiles: sm.AtMobiles, + AtUserIds: sm.AtUserIds, + AtAll: sm.AtAll, + } + + // 使用 SendUnified 方法(自动格式转换和@功能支持) + res, errMsg := channel.SendUnified(msgObj, ins.SendTasksIns, unifiedContent) if res != "" { sm.LogsAndStatusMark(fmt.Sprintf("返回内容:%s", res), sm.Status) + } else { + sm.LogsAndStatusMark(sm.TransError(errMsg), errStrIsSuccess(errMsg)) } - sm.LogsAndStatusMark(sm.TransError(errMsg), errStrIsSuccess(errMsg)) } @@ -221,18 +241,18 @@ func (sm *SendMessageService) TransError(err string) string { // 先根据实例设置的类型取,取不到或者取到的是空,则使用text发送 func (sm *SendMessageService) GetSendMsg(ins models.SendTasksIns) (string, string) { data := map[string]string{} - data["text"] = sm.Text - data["html"] = sm.HTML - data["markdown"] = sm.MarkDown + data[unified.FormatTypeText] = sm.Text + data[unified.FormatTypeHTML] = sm.HTML + data[unified.FormatTypeMarkdown] = sm.MarkDown content, ok := data[strings.ToLower(ins.ContentType)] if !ok || len(content) == 0 { - content, ok := data["text"] + content, ok := data[unified.FormatTypeText] if !ok { logrus.Error("text节点数据为空!") - return "text", "" + return unified.FormatTypeText, "" } else { logrus.Error(fmt.Sprintf("没有找到%s对应的消息,使用text消息替代!", ins.ContentType)) - return "text", content + return unified.FormatTypeText, content } } else { return strings.ToLower(ins.ContentType), content diff --git a/service/send_message_service/send_qywexin.go b/service/send_message_service/send_qywexin.go deleted file mode 100644 index 029b5ce..0000000 --- a/service/send_message_service/send_qywexin.go +++ /dev/null @@ -1,46 +0,0 @@ -package send_message_service - -import ( - "fmt" - "message-nest/models" - "message-nest/pkg/message" - "message-nest/service/send_ins_service" - "message-nest/service/send_way_service" -) - -type QyWeiXinService struct { -} - -// SendDtalkMessage 执行发送钉钉 -func (s *QyWeiXinService) SendQyWeiXinMessage(auth send_way_service.WayDetailQyWeiXin, ins models.SendTasksIns, typeC string, title string, content string) (string, string) { - insService := send_ins_service.SendTaskInsService{} - errStr, c := insService.ValidateDiffIns(ins) - if errStr != "" { - return errStr, "" - } - _, ok := c.(models.InsQyWeiXinConfig) - if !ok { - return "企业微信config校验失败", "" - } - - errMsg := "" - var res []byte - var err error - cli := message.QyWeiXin{ - AccessToken: auth.AccessToken, - } - if typeC == "text" { - res, err = cli.SendMessageText(content) - if err != nil { - errMsg = fmt.Sprintf("发送失败:%s", ins.ContentType) - } - } else if typeC == "markdown" { - res, err = cli.SendMessageMarkdown(title, content) - if err != nil { - errMsg = fmt.Sprintf("发送失败:%s", ins.ContentType) - } - } else { - errMsg = fmt.Sprintf("未知的企业微信发送内容类型:%s", ins.ContentType) - } - return string(res), errMsg -} diff --git a/service/send_message_service/send_wechat_account.go b/service/send_message_service/send_wechat_account.go deleted file mode 100644 index 971dada..0000000 --- a/service/send_message_service/send_wechat_account.go +++ /dev/null @@ -1,42 +0,0 @@ -package send_message_service - -import ( - "fmt" - "message-nest/models" - "message-nest/pkg/message" - "message-nest/service/send_ins_service" - "message-nest/service/send_way_service" -) - -type WeChatOfAccountService struct { -} - -// SendWeChatOfAccountMessage 执行发送微信公众号模板消息 -func (s *WeChatOfAccountService) SendWeChatOfAccountMessage(auth send_way_service.WeChatOFAccount, ins models.SendTasksIns, typeC string, title string, content string, url string) (string, string) { - insService := send_ins_service.SendTaskInsService{} - errStr, c := insService.ValidateDiffIns(ins) - if errStr != "" { - return errStr, "" - } - config, ok := c.(models.InsWeChatAccountConfig) - if !ok { - return "微信公众号模板消息config校验失败", "" - } - var ( - err error - res string - errMsg string - ) - cli := message.WeChatOFAccount{ - AppID: auth.AppID, - AppSecret: auth.APPSecret, - TemplateID: auth.TempID, - ToUser: config.ToAccount, - URL: url, - } - res, err = cli.Send(title, content) - if err != nil { - errMsg = fmt.Sprintf("发送失败:%s", ins.ContentType) - } - return res, errMsg -} diff --git a/service/send_message_service/unified/channel_registry.go b/service/send_message_service/unified/channel_registry.go new file mode 100644 index 0000000..5579c87 --- /dev/null +++ b/service/send_message_service/unified/channel_registry.go @@ -0,0 +1,126 @@ +package unified + +import ( + "fmt" + "message-nest/service/send_message_service/unified/channels" + "sync" +) + +// 重导出channels包的类型,方便外部使用 +type ( + Channel = channels.Channel + UnifiedMessageContent = channels.UnifiedMessageContent +) + +// 重导出常量 +const ( + FormatTypeText = channels.FormatTypeText + FormatTypeHTML = channels.FormatTypeHTML + FormatTypeMarkdown = channels.FormatTypeMarkdown + + MessageTypeEmail = channels.MessageTypeEmail + MessageTypeDtalk = channels.MessageTypeDtalk + MessageTypeQyWeiXin = channels.MessageTypeQyWeiXin + MessageTypeCustom = channels.MessageTypeCustom + MessageTypeWeChatOFAccount = channels.MessageTypeWeChatOFAccount + MessageTypeMessageNest = channels.MessageTypeMessageNest +) + +// ChannelRegistry 渠道注册表 +type ChannelRegistry struct { + channels map[string]Channel + mu sync.RWMutex +} + +// NewChannelRegistry 创建渠道注册表 +func NewChannelRegistry() *ChannelRegistry { + return &ChannelRegistry{ + channels: make(map[string]Channel), + } +} + +// Register 注册渠道 +func (r *ChannelRegistry) Register(channel Channel) { + r.mu.Lock() + defer r.mu.Unlock() + r.channels[channel.GetType()] = channel +} + +// GetChannel 获取渠道 +func (r *ChannelRegistry) GetChannel(channelType string) (Channel, bool) { + r.mu.RLock() + defer r.mu.RUnlock() + channel, ok := r.channels[channelType] + return channel, ok +} + +// GetAllChannels 获取所有渠道 +func (r *ChannelRegistry) GetAllChannels() map[string]Channel { + r.mu.RLock() + defer r.mu.RUnlock() + + result := make(map[string]Channel, len(r.channels)) + for k, v := range r.channels { + result[k] = v + } + return result +} + +// ListChannels 列出所有渠道 +func (r *ChannelRegistry) ListChannels() []string { + r.mu.RLock() + defer r.mu.RUnlock() + + types := make([]string, 0, len(r.channels)) + for t := range r.channels { + types = append(types, t) + } + return types +} + +var ( + globalChannelRegistry *ChannelRegistry + channelRegistryOnce sync.Once +) + +// GetGlobalChannelRegistry 获取全局渠道注册表(单例) +func GetGlobalChannelRegistry() *ChannelRegistry { + channelRegistryOnce.Do(func() { + globalChannelRegistry = NewChannelRegistry() + + // 注册所有渠道 + globalChannelRegistry.Register(channels.NewEmailChannel()) + globalChannelRegistry.Register(channels.NewDtalkChannel()) + globalChannelRegistry.Register(channels.NewQyWeiXinChannel()) + globalChannelRegistry.Register(channels.NewCustomChannel()) + globalChannelRegistry.Register(channels.NewWeChatOFAccountChannel()) + globalChannelRegistry.Register(channels.NewMessageNestChannel()) + }) + return globalChannelRegistry +} + +// GetChannelInfo 获取渠道信息(用于调试和文档) +func GetChannelInfo(channelType string) (string, error) { + registry := GetGlobalChannelRegistry() + channel, ok := registry.GetChannel(channelType) + if !ok { + return "", fmt.Errorf("未知的渠道类型: %s", channelType) + } + + info := fmt.Sprintf("渠道: %s\n支持格式: %v\n", + channel.GetType(), + channel.GetSupportedFormats()) + return info, nil +} + +// ListAllChannels 列出所有渠道信息 +func ListAllChannels() string { + registry := GetGlobalChannelRegistry() + allChannels := registry.GetAllChannels() + + result := "已注册的渠道:\n" + for channelType, channel := range allChannels { + result += fmt.Sprintf(" - %s: %v\n", channelType, channel.GetSupportedFormats()) + } + return result +} diff --git a/service/send_message_service/unified/channels/base.go b/service/send_message_service/unified/channels/base.go new file mode 100644 index 0000000..aa9f3af --- /dev/null +++ b/service/send_message_service/unified/channels/base.go @@ -0,0 +1,45 @@ +package channels + +import "message-nest/models" + +type Channel interface { + GetType() string + GetSupportedFormats() []string + FormatContent(content *UnifiedMessageContent) (formatType string, formattedContent string, err error) + SendUnified(msgObj interface{}, ins models.SendTasksIns, content *UnifiedMessageContent) (string, string) +} + +type BaseChannel struct { + channelType string + supportedFormats []string +} + +func NewBaseChannel(channelType string, supportedFormats []string) *BaseChannel { + return &BaseChannel{channelType: channelType, supportedFormats: supportedFormats} +} + +func (c *BaseChannel) GetType() string { return c.channelType } +func (c *BaseChannel) GetSupportedFormats() []string { return c.supportedFormats } + +func (c *BaseChannel) FormatContent(content *UnifiedMessageContent) (string, string, error) { + for _, formatType := range c.supportedFormats { + switch formatType { + case FormatTypeMarkdown: + if content.HasMarkdown() { + return FormatTypeMarkdown, content.Markdown, nil + } + case FormatTypeHTML: + if content.HasHTML() { + return FormatTypeHTML, content.HTML, nil + } + case FormatTypeText: + if content.HasText() { + return FormatTypeText, content.Text, nil + } + } + } + if content.HasText() { + return FormatTypeText, content.Text, nil + } + return FormatTypeText, "", nil +} diff --git a/service/send_message_service/unified/channels/custom.go b/service/send_message_service/unified/channels/custom.go new file mode 100644 index 0000000..4bcc21d --- /dev/null +++ b/service/send_message_service/unified/channels/custom.go @@ -0,0 +1,83 @@ +package channels + +import ( + "encoding/json" + "fmt" + "message-nest/models" + "message-nest/pkg/message" + "message-nest/service/send_way_service" + "regexp" + "strings" +) + +type CustomChannel struct{ *BaseChannel } + +func NewCustomChannel() *CustomChannel { + return &CustomChannel{BaseChannel: NewBaseChannel(MessageTypeCustom, []string{FormatTypeText})} +} + +func (c *CustomChannel) FormatContent(content *UnifiedMessageContent) (string, string, error) { + if content.HasText() { + return FormatTypeText, content.Text, nil + } + if content.HasMarkdown() { + return FormatTypeText, markdownToText(content.Markdown), nil + } + if content.HasHTML() { + return FormatTypeText, htmlToText(content.HTML), nil + } + return FormatTypeText, "", nil +} + +func (c *CustomChannel) SendUnified(msgObj interface{}, ins models.SendTasksIns, content *UnifiedMessageContent) (string, string) { + auth, ok := msgObj.(send_way_service.WayDetailCustom) + if !ok { + return "", "类型转换失败" + } + _, formattedContent, err := c.FormatContent(content) + if err != nil { + return "", err.Error() + } + cli := message.CustomWebhook{} + data, _ := json.Marshal(formattedContent) + dataStr := strings.Trim(string(data), "\"") + bodyStr := strings.Replace(auth.Body, "TEXT", dataStr, -1) + res, err := cli.Request(auth.Webhook, bodyStr) + var errMsg string + if err != nil { + errMsg = fmt.Sprintf("发送失败:%s", err.Error()) + } + return string(res), errMsg +} + +func markdownToText(md string) string { + t := md + t = regexp.MustCompile(`#{1,6}\s+`).ReplaceAllString(t, "") + t = regexp.MustCompile(`\*\*([^*]+)\*\*`).ReplaceAllString(t, "$1") + t = regexp.MustCompile(`\*([^*]+)\*`).ReplaceAllString(t, "$1") + t = regexp.MustCompile(`__([^_]+)__`).ReplaceAllString(t, "$1") + t = regexp.MustCompile(`_([^_]+)_`).ReplaceAllString(t, "$1") + t = regexp.MustCompile(`\[([^\]]+)\]\([^)]+\)`).ReplaceAllString(t, "$1") + t = regexp.MustCompile("```[^`]*```").ReplaceAllString(t, "") + t = regexp.MustCompile("`([^`]+)`").ReplaceAllString(t, "$1") + t = regexp.MustCompile(`(?m)^>\s+`).ReplaceAllString(t, "") + t = regexp.MustCompile(`(?m)^[\*\-\+]\s+`).ReplaceAllString(t, "") + t = regexp.MustCompile(`(?m)^\d+\.\s+`).ReplaceAllString(t, "") + return strings.TrimSpace(t) +} + +func htmlToText(html string) string { + t := html + t = regexp.MustCompile(`(?i)]*>.*?`).ReplaceAllString(t, "") + t = regexp.MustCompile(`(?i)]*>.*?`).ReplaceAllString(t, "") + t = regexp.MustCompile(`(?i)`).ReplaceAllString(t, "\n") + t = regexp.MustCompile(`(?i)

`).ReplaceAllString(t, "\n") + t = regexp.MustCompile(`<[^>]+>`).ReplaceAllString(t, "") + t = strings.ReplaceAll(t, " ", " ") + t = strings.ReplaceAll(t, "<", "<") + t = strings.ReplaceAll(t, ">", ">") + t = strings.ReplaceAll(t, "&", "&") + t = strings.ReplaceAll(t, """, "\"") + t = regexp.MustCompile(`\n{3,}`).ReplaceAllString(t, "\n\n") + return strings.TrimSpace(t) +} diff --git a/service/send_message_service/unified/channels/dtalk.go b/service/send_message_service/unified/channels/dtalk.go new file mode 100644 index 0000000..7eedd2a --- /dev/null +++ b/service/send_message_service/unified/channels/dtalk.go @@ -0,0 +1,56 @@ +package channels + +import ( + "fmt" + "message-nest/models" + "message-nest/pkg/message" + "message-nest/service/send_ins_service" + "message-nest/service/send_way_service" +) + +type DtalkChannel struct{ *BaseChannel } + +func NewDtalkChannel() *DtalkChannel { + return &DtalkChannel{BaseChannel: NewBaseChannel(MessageTypeDtalk, []string{FormatTypeMarkdown, FormatTypeText})} +} + +func (c *DtalkChannel) SendUnified(msgObj interface{}, ins models.SendTasksIns, content *UnifiedMessageContent) (string, string) { + auth, ok := msgObj.(send_way_service.WayDetailDTalk) + if !ok { + return "", "类型转换失败" + } + insService := send_ins_service.SendTaskInsService{} + errStr, configInterface := insService.ValidateDiffIns(ins) + if errStr != "" { + return errStr, "" + } + _, ok = configInterface.(models.InsDtalkConfig) + if !ok { + return "钉钉config校验失败", "" + } + contentType, formattedContent, err := c.FormatContent(content) + if err != nil { + return "", err.Error() + } + atMobiles := content.GetAtMobiles() + if content.IsAtAll() { + atMobiles = append(atMobiles, "all") + } + cli := message.Dtalk{AccessToken: auth.AccessToken, Secret: auth.Secret} + var res []byte + var errMsg string + if contentType == FormatTypeText { + res, err = cli.SendMessageText(formattedContent, atMobiles...) + if err != nil { + errMsg = fmt.Sprintf("发送失败:%s", err.Error()) + } + } else if contentType == FormatTypeMarkdown { + res, err = cli.SendMessageMarkdown(content.Title, formattedContent, atMobiles...) + if err != nil { + errMsg = fmt.Sprintf("发送失败:%s", err.Error()) + } + } else { + errMsg = fmt.Sprintf("未知的钉钉发送内容类型:%s", contentType) + } + return string(res), errMsg +} diff --git a/service/send_message_service/unified/channels/email.go b/service/send_message_service/unified/channels/email.go new file mode 100644 index 0000000..4926d8c --- /dev/null +++ b/service/send_message_service/unified/channels/email.go @@ -0,0 +1,46 @@ +package channels + +import ( + "fmt" + "message-nest/models" + "message-nest/pkg/message" + "message-nest/service/send_ins_service" + "message-nest/service/send_way_service" +) + +type EmailChannel struct{ *BaseChannel } + +func NewEmailChannel() *EmailChannel { + return &EmailChannel{BaseChannel: NewBaseChannel(MessageTypeEmail, []string{FormatTypeHTML, FormatTypeText})} +} + +func (c *EmailChannel) SendUnified(msgObj interface{}, ins models.SendTasksIns, content *UnifiedMessageContent) (string, string) { + auth, ok := msgObj.(send_way_service.WayDetailEmail) + if !ok { + return "", "类型转换失败" + } + insService := send_ins_service.SendTaskInsService{} + errStr, configInterface := insService.ValidateDiffIns(ins) + if errStr != "" { + return "", errStr + } + config, ok := configInterface.(models.InsEmailConfig) + if !ok { + return "", "邮箱config校验失败" + } + contentType, formattedContent, err := c.FormatContent(content) + if err != nil { + return "", err.Error() + } + var emailer message.EmailMessage + emailer.Init(auth.Server, auth.Port, auth.Account, auth.Passwd) + var errMsg string + if contentType == FormatTypeText { + errMsg = emailer.SendTextMessage(config.ToAccount, content.Title, formattedContent) + } else if contentType == FormatTypeHTML { + errMsg = emailer.SendHtmlMessage(config.ToAccount, content.Title, formattedContent) + } else { + errMsg = fmt.Sprintf("未知的邮件发送内容类型:%s", contentType) + } + return "", errMsg +} diff --git a/service/send_message_service/unified/channels/nest.go b/service/send_message_service/unified/channels/nest.go new file mode 100644 index 0000000..17fb182 --- /dev/null +++ b/service/send_message_service/unified/channels/nest.go @@ -0,0 +1,38 @@ +package channels + +import ( + "message-nest/models" + "message-nest/service/hosted_message_service" + "message-nest/service/send_way_service" +) + +type MessageNestChannel struct{ *BaseChannel } + +func NewMessageNestChannel() *MessageNestChannel { + return &MessageNestChannel{BaseChannel: NewBaseChannel(MessageTypeMessageNest, []string{FormatTypeMarkdown, FormatTypeHTML, FormatTypeText})} +} + +func (c *MessageNestChannel) SendUnified(msgObj interface{}, ins models.SendTasksIns, content *UnifiedMessageContent) (string, string) { + _, ok := msgObj.(send_way_service.MessageNest) + if !ok { + return "", "类型转换失败" + } + contentType, formattedContent, err := c.FormatContent(content) + if err != nil { + return "", err.Error() + } + messageService := hosted_message_service.HostMessageService{ + Title: content.Title, + Content: formattedContent, + Type: contentType, + } + err = messageService.Add() + var res, errMsg string + if err != nil { + errMsg = err.Error() + res = "托管消息创建失败!" + } else { + res = "托管消息创建成功!" + } + return res, errMsg +} diff --git a/service/send_message_service/unified/channels/qyweixin.go b/service/send_message_service/unified/channels/qyweixin.go new file mode 100644 index 0000000..50377ef --- /dev/null +++ b/service/send_message_service/unified/channels/qyweixin.go @@ -0,0 +1,58 @@ +package channels + +import ( + "fmt" + "message-nest/models" + "message-nest/pkg/message" + "message-nest/service/send_ins_service" + "message-nest/service/send_way_service" +) + +type QyWeiXinChannel struct{ *BaseChannel } + +func NewQyWeiXinChannel() *QyWeiXinChannel { + return &QyWeiXinChannel{BaseChannel: NewBaseChannel(MessageTypeQyWeiXin, []string{FormatTypeMarkdown, FormatTypeText})} +} + +func (c *QyWeiXinChannel) SendUnified(msgObj interface{}, ins models.SendTasksIns, content *UnifiedMessageContent) (string, string) { + auth, ok := msgObj.(send_way_service.WayDetailQyWeiXin) + if !ok { + return "", "类型转换失败" + } + insService := send_ins_service.SendTaskInsService{} + errStr, configInterface := insService.ValidateDiffIns(ins) + if errStr != "" { + return errStr, "" + } + _, ok = configInterface.(models.InsQyWeiXinConfig) + if !ok { + return "企业微信config校验失败", "" + } + contentType, formattedContent, err := c.FormatContent(content) + if err != nil { + return "", err.Error() + } + atList := []string{} + atList = append(atList, content.GetAtUserIds()...) + atList = append(atList, content.GetAtMobiles()...) + if content.IsAtAll() { + atList = append(atList, "@all") + } + cli := message.QyWeiXin{AccessToken: auth.AccessToken} + var res []byte + var errMsg string + if contentType == FormatTypeText { + res, err = cli.SendMessageText(formattedContent, atList...) + if err != nil { + errMsg = fmt.Sprintf("发送失败:%s", err.Error()) + } + } else if contentType == FormatTypeMarkdown { + res, err = cli.SendMessageMarkdown(content.Title, formattedContent, atList...) + if err != nil { + errMsg = fmt.Sprintf("发送失败:%s", err.Error()) + } + } else { + errMsg = fmt.Sprintf("未知的企业微信发送内容类型:%s", contentType) + } + return string(res), errMsg +} diff --git a/service/send_message_service/unified/channels/types.go b/service/send_message_service/unified/channels/types.go new file mode 100644 index 0000000..909b891 --- /dev/null +++ b/service/send_message_service/unified/channels/types.go @@ -0,0 +1,53 @@ +package channels + +// 消息格式类型常量 +const ( + FormatTypeText = "text" + FormatTypeHTML = "html" + FormatTypeMarkdown = "markdown" +) + +// 消息类型常量 +const ( + MessageTypeEmail = "Email" + MessageTypeDtalk = "Dtalk" + MessageTypeQyWeiXin = "QyWeiXin" + MessageTypeCustom = "Custom" + MessageTypeWeChatOFAccount = "WeChatOFAccount" + MessageTypeMessageNest = "MessageNest" +) + +// UnifiedMessageContent 统一的消息内容结构 +type UnifiedMessageContent struct { + Title string + URL string + Text string + HTML string + Markdown string + AtMobiles []string + AtUserIds []string + AtAll bool + Summary string + ImageURL string + Extra map[string]interface{} +} + +func (m *UnifiedMessageContent) HasText() bool { return m.Text != "" } +func (m *UnifiedMessageContent) HasHTML() bool { return m.HTML != "" } +func (m *UnifiedMessageContent) HasMarkdown() bool { return m.Markdown != "" } + +func (m *UnifiedMessageContent) GetAtMobiles() []string { + if m.AtMobiles == nil { + return []string{} + } + return m.AtMobiles +} + +func (m *UnifiedMessageContent) GetAtUserIds() []string { + if m.AtUserIds == nil { + return []string{} + } + return m.AtUserIds +} + +func (m *UnifiedMessageContent) IsAtAll() bool { return m.AtAll } diff --git a/service/send_message_service/unified/channels/wechat.go b/service/send_message_service/unified/channels/wechat.go new file mode 100644 index 0000000..6743ee9 --- /dev/null +++ b/service/send_message_service/unified/channels/wechat.go @@ -0,0 +1,48 @@ +package channels + +import ( + "fmt" + "message-nest/models" + "message-nest/pkg/message" + "message-nest/service/send_ins_service" + "message-nest/service/send_way_service" +) + +type WeChatOFAccountChannel struct{ *BaseChannel } + +func NewWeChatOFAccountChannel() *WeChatOFAccountChannel { + return &WeChatOFAccountChannel{BaseChannel: NewBaseChannel(MessageTypeWeChatOFAccount, []string{FormatTypeText})} +} + +func (c *WeChatOFAccountChannel) SendUnified(msgObj interface{}, ins models.SendTasksIns, content *UnifiedMessageContent) (string, string) { + auth, ok := msgObj.(send_way_service.WeChatOFAccount) + if !ok { + return "", "类型转换失败" + } + insService := send_ins_service.SendTaskInsService{} + errStr, configInterface := insService.ValidateDiffIns(ins) + if errStr != "" { + return errStr, "" + } + config, ok := configInterface.(models.InsWeChatAccountConfig) + if !ok { + return "微信公众号模板消息config校验失败", "" + } + _, formattedContent, err := c.FormatContent(content) + if err != nil { + return "", err.Error() + } + cli := message.WeChatOFAccount{ + AppID: auth.AppID, + AppSecret: auth.APPSecret, + TemplateID: auth.TempID, + ToUser: config.ToAccount, + URL: content.URL, + } + res, err := cli.Send(content.Title, formattedContent) + var errMsg string + if err != nil { + errMsg = fmt.Sprintf("发送失败:%s", err.Error()) + } + return res, errMsg +} diff --git a/web/src/components/pages/sendTasks/ApiCodeViewer.vue b/web/src/components/pages/sendTasks/ApiCodeViewer.vue index 183857d..59641a2 100644 --- a/web/src/components/pages/sendTasks/ApiCodeViewer.vue +++ b/web/src/components/pages/sendTasks/ApiCodeViewer.vue @@ -42,6 +42,14 @@ export default defineComponent({ // 当前选中的标签 const activeTab = ref('curl') + // 可选参数选项 + const showHtml = ref(false) + const showMarkdown = ref(false) + const showUrl = ref(false) + const showAtMobiles = ref(false) + const showAtUserIds = ref(false) + const showAtAll = ref(false) + // 代码语言选项 const codeLanguages = [ { value: 'curl', label: 'cURL', icon: '🌐' }, @@ -56,7 +64,14 @@ export default defineComponent({ // 生成API代码示例 const generateApiCode = (language: string) => { const taskId = props.taskData?.id || 'TASK_ID' - const options = { html: false, markdown: false, url: false } + const options = { + html: showHtml.value, + markdown: showMarkdown.value, + url: showUrl.value, + at_mobiles: showAtMobiles.value, + at_user_ids: showAtUserIds.value, + at_all: showAtAll.value + } switch (language) { case 'curl': @@ -91,6 +106,12 @@ export default defineComponent({ return { handleUpdateOpen, activeTab, + showHtml, + showMarkdown, + showUrl, + showAtMobiles, + showAtUserIds, + showAtAll, codeLanguages, generateApiCode, copyToClipboard @@ -122,6 +143,41 @@ export default defineComponent({ --> + +
+

可选参数

+
+ + + + + + +
+

💡 提示:@功能仅钉钉和企业微信支持

+
+

代码示例

diff --git a/web/src/util/viewApi.js b/web/src/util/viewApi.js index 188619f..751be02 100644 --- a/web/src/util/viewApi.js +++ b/web/src/util/viewApi.js @@ -40,6 +40,16 @@ class ApiStrGenerate { if (options.url) { data.url = 'https://github.com'; } + // @提及功能参数(可选) + if (options.at_mobiles) { + data.at_mobiles = ['13800138000', '13900139000']; + } + if (options.at_user_ids) { + data.at_user_ids = ['zhangsan', 'lisi']; + } + if (options.at_all) { + data.at_all = true; + } let dataStr = JSON.stringify(data, null, 4); return dataStr } From a5115127930149e62290f27b92cd586a681efb78 Mon Sep 17 00:00:00 2001 From: engigu Date: Sat, 6 Dec 2025 00:28:18 +0800 Subject: [PATCH 03/24] feat: add message template --- middleware/jwt.go | 4 +- migrate/migrate.go | 6 +- models/message_template.go | 149 +++++++ models/send_ins.go | 23 + models/send_tasks_logs.go | 81 +++- routers/api/v1/message_template.go | 336 +++++++++++++++ routers/api/v1/send_message.go | 1 + routers/api/v2/send_message.go | 146 +++++++ routers/router.go | 21 + .../message_template.go | 185 +++++++++ service/send_message_service/send_message.go | 213 ++++++++-- web/src/components/Index.vue | 5 +- .../pages/messageTemplate/MessageTemplate.vue | 380 +++++++++++++++++ .../messageTemplate/TemplateApiViewer.vue | 180 ++++++++ .../pages/messageTemplate/TemplateEditor.vue | 392 ++++++++++++++++++ .../TemplateInstanceConfig.vue | 352 ++++++++++++++++ .../components/pages/sendLogs/SendLogs.vue | 40 +- .../pages/sendTasks/ApiCodeViewer.vue | 5 +- web/src/constant.js | 2 +- web/src/router/index.js | 5 + web/src/util/viewApi.js | 237 +++++++---- 21 files changed, 2608 insertions(+), 155 deletions(-) create mode 100644 models/message_template.go create mode 100644 routers/api/v1/message_template.go create mode 100644 routers/api/v2/send_message.go create mode 100644 service/message_template_service/message_template.go create mode 100644 web/src/components/pages/messageTemplate/MessageTemplate.vue create mode 100644 web/src/components/pages/messageTemplate/TemplateApiViewer.vue create mode 100644 web/src/components/pages/messageTemplate/TemplateEditor.vue create mode 100644 web/src/components/pages/messageTemplate/TemplateInstanceConfig.vue diff --git a/middleware/jwt.go b/middleware/jwt.go index f498fda..0e53e45 100644 --- a/middleware/jwt.go +++ b/middleware/jwt.go @@ -2,9 +2,10 @@ package middleware import ( "errors" - "github.com/golang-jwt/jwt/v5" "net/http" + "github.com/golang-jwt/jwt/v5" + "github.com/gin-gonic/gin" "message-nest/pkg/e" @@ -13,6 +14,7 @@ import ( var ExcludedRoutes = []string{ "/api/v1/message/send", + "/api/v2/message/send", "/api/v1/settings/getsetting", } diff --git a/migrate/migrate.go b/migrate/migrate.go index 2213b7d..9499ccd 100644 --- a/migrate/migrate.go +++ b/migrate/migrate.go @@ -3,10 +3,11 @@ package migrate import ( "errors" "fmt" - "github.com/sirupsen/logrus" - "gorm.io/gorm" "message-nest/models" "message-nest/service/settings_service" + + "github.com/sirupsen/logrus" + "gorm.io/gorm" ) // 初始化admin账户 @@ -67,6 +68,7 @@ func Setup() { &models.CronMessages{}, &models.HostedMessage{}, &models.LoginLog{}, + &models.MessageTemplate{}, } for _, table := range tables { diff --git a/models/message_template.go b/models/message_template.go new file mode 100644 index 0000000..d8f65e6 --- /dev/null +++ b/models/message_template.go @@ -0,0 +1,149 @@ +package models + +import ( + "fmt" + "message-nest/pkg/util" +) + +// GenerateTemplateUniqueID 生成模板唯一ID +func GenerateTemplateUniqueID() string { + newUUID := util.GenerateUniqueID() + return fmt.Sprintf("TP%s", newUUID) +} + +// MessageTemplate 消息模板 +type MessageTemplate struct { + UUIDModel + + Name string `json:"name" gorm:"type:varchar(200);not null;index" binding:"required"` + Description string `json:"description" gorm:"type:text"` + + // 模板内容(带占位符) + TextTemplate string `json:"text_template" gorm:"type:text"` + HTMLTemplate string `json:"html_template" gorm:"type:text"` + MarkdownTemplate string `json:"markdown_template" gorm:"type:text"` + + // 占位符定义(JSON格式) + Placeholders string `json:"placeholders" gorm:"type:text"` + + // @提醒配置 + AtMobiles string `json:"at_mobiles" gorm:"type:text;comment:'@手机号列表,逗号分隔'"` + AtUserIds string `json:"at_user_ids" gorm:"type:text;comment:'@用户ID列表,逗号分隔'"` + IsAtAll bool `json:"is_at_all" gorm:"default:false;comment:'是否@所有人'"` + + // 状态:enabled/disabled + Status string `json:"status" gorm:"type:varchar(20);default:'enabled';index"` +} + +// Add 添加消息模板 +func (t *MessageTemplate) Add() error { + if err := db.Create(&t).Error; err != nil { + return err + } + return nil +} + +// Update 更新消息模板 +func (t *MessageTemplate) Update() error { + if err := db.Model(&MessageTemplate{}).Where("id = ?", t.ID).Updates(t).Error; err != nil { + return err + } + return nil +} + +// Delete 删除消息模板 +func (t *MessageTemplate) Delete() error { + if err := db.Where("id = ?", t.ID).Delete(&MessageTemplate{}).Error; err != nil { + return err + } + return nil +} + +// MessageTemplateResult 消息模板查询结果 +type MessageTemplateResult struct { + ID string `json:"id"` + Name string `json:"name"` + Description string `json:"description"` + TextTemplate string `json:"text_template"` + HTMLTemplate string `json:"html_template"` + MarkdownTemplate string `json:"markdown_template"` + Placeholders string `json:"placeholders"` + AtMobiles string `json:"at_mobiles"` + AtUserIds string `json:"at_user_ids"` + IsAtAll bool `json:"is_at_all"` + Status string `json:"status"` + CreatedBy string `json:"created_by"` + ModifiedBy string `json:"modified_by"` + CreatedOn util.Time `json:"created_on"` + ModifiedOn util.Time `json:"modified_on"` +} + +// GetMessageTemplates 获取消息模板列表 +func GetMessageTemplates(pageNum int, pageSize int, text string, maps map[string]interface{}) ([]MessageTemplateResult, error) { + var datas []MessageTemplateResult + templateT := GetSchema(MessageTemplate{}) + + query := db.Table(templateT) + query = query.Where(maps) + + if text != "" { + query = query.Where("name LIKE ? OR description LIKE ?", + fmt.Sprintf("%%%s%%", text), + fmt.Sprintf("%%%s%%", text)) + } + + query = query.Order("created_on DESC") + + if pageSize > 0 || pageNum > 0 { + query = query.Offset(pageNum).Limit(pageSize) + } + + query.Scan(&datas) + return datas, nil +} + +// GetMessageTemplatesTotal 获取消息模板总数 +func GetMessageTemplatesTotal(text string, maps map[string]interface{}) (int64, error) { + var total int64 + templateT := GetSchema(MessageTemplate{}) + + query := db.Table(templateT) + query = query.Where(maps) + + if text != "" { + query = query.Where("name LIKE ? OR description LIKE ?", + fmt.Sprintf("%%%s%%", text), + fmt.Sprintf("%%%s%%", text)) + } + + query.Count(&total) + return total, nil +} + +// GetMessageTemplateByID 根据ID获取消息模板 +func GetMessageTemplateByID(id string) (*MessageTemplateResult, error) { + var data MessageTemplateResult + templateT := GetSchema(MessageTemplate{}) + + err := db.Table(templateT).Where("id = ?", id).First(&data).Error + if err != nil { + return nil, err + } + + return &data, nil +} + +// ExistMessageTemplateByID 检查模板是否存在 +func ExistMessageTemplateByID(id string) (bool, error) { + var template MessageTemplate + err := db.Select("id").Where("id = ?", id).First(&template).Error + if err != nil { + return false, err + } + + if template.ID != "" { + return true, nil + } + + return false, nil +} diff --git a/models/send_ins.go b/models/send_ins.go index c8049d3..1325d39 100644 --- a/models/send_ins.go +++ b/models/send_ins.go @@ -1,9 +1,12 @@ package models +import "fmt" + type SendTasksIns struct { UUIDModel TaskID string `json:"task_id" gorm:"type:varchar(12) ;default:'';index"` + TemplateID string `json:"template_id" gorm:"type:varchar(12) ;default:'';index"` // 模板ID WayID string `json:"way_id" gorm:"type:varchar(12) ;default:'';index"` WayType string `json:"way_type" gorm:"type:varchar(100) ;default:'';index"` ContentType string `json:"content_type" gorm:"type:varchar(100) ;default:'';index"` @@ -82,3 +85,23 @@ func UpdateMsgTaskIns(id string, data map[string]interface{}) error { } return nil } + +// GetTemplateInsList 获取模板关联的实例列表(包含渠道名称) +func GetTemplateInsList(templateID string) ([]SendTasksInsRes, error) { + insTable := GetSchema(SendTasksIns{}) + waysTable := GetSchema(SendWays{}) + var insList []SendTasksInsRes + + err := db. + Table(insTable). + Select(fmt.Sprintf("%s.*, %s.name as way_name", insTable, waysTable)). + Joins(fmt.Sprintf("JOIN %s ON %s.way_id = %s.id", waysTable, insTable, waysTable)). + Where(fmt.Sprintf("%s.template_id = ?", insTable), templateID). + Order(fmt.Sprintf("%s.created_on DESC", insTable)). + Scan(&insList).Error + + if err != nil { + return nil, err + } + return insList, nil +} diff --git a/models/send_tasks_logs.go b/models/send_tasks_logs.go index f185a67..dd4a210 100644 --- a/models/send_tasks_logs.go +++ b/models/send_tasks_logs.go @@ -9,6 +9,8 @@ import ( type SendTasksLogs struct { ID int `gorm:"primaryKey" json:"id" ` TaskID string `json:"task_id" gorm:"type:varchar(12) ;default:'';index:task_id"` + Type string `json:"type" gorm:"type:varchar(20) ;default:'task';comment:'类型:task-任务,template-模板'"` + Name string `json:"name" gorm:"type:varchar(256) ;default:'';comment:'任务或模板名称'"` Log string `json:"log" gorm:"type:text ;"` Status *int `json:"status" gorm:"type:int ;default:0;"` CallerIp string `json:"caller_ip" gorm:"type:varchar(256) ;default:'';"` @@ -29,10 +31,11 @@ func (log *SendTasksLogs) Add() error { type LogsResult struct { ID int `json:"id"` TaskID string `json:"task_id"` + Type string `json:"type"` + Name string `json:"name"` Log string `json:"log"` CreatedOn util.Time `json:"created_on"` ModifiedOn util.Time `json:"modified_on"` - TaskName string `json:"task_name"` Status int `json:"status"` CallerIp string `json:"caller_ip"` } @@ -41,12 +44,9 @@ type LogsResult struct { func GetSendLogs(pageNum int, pageSize int, name string, taskId string, maps map[string]interface{}) ([]LogsResult, error) { var logs []LogsResult logt := GetSchema(SendTasksLogs{}) - taskt := GetSchema(SendTasks{}) - query := db. - Table(logt). - Select(fmt.Sprintf("%s.*, %s.name as task_name", logt, taskt)). - Joins(fmt.Sprintf("LEFT JOIN %s ON %s.task_id = %s.id", taskt, logt, taskt)) + // 简化查询,只查询日志表 + query := db.Table(logt) dayVal, ok := maps["day_created_on"] if ok { @@ -55,8 +55,10 @@ func GetSendLogs(pageNum int, pageSize int, name string, taskId string, maps map } query = query.Where(maps) + + // 按名称搜索(搜索日志表的 name 字段) if name != "" { - query = query.Where(fmt.Sprintf("%s.name like ?", taskt), fmt.Sprintf("%%%s%%", name)) + query = query.Where(fmt.Sprintf("%s.name like ?", logt), fmt.Sprintf("%%%s%%", name)) } if taskId != "" { query = query.Where(fmt.Sprintf("%s.task_id = ?", logt), taskId) @@ -66,18 +68,71 @@ func GetSendLogs(pageNum int, pageSize int, name string, taskId string, maps map query = query.Offset(pageNum).Limit(pageSize) } query.Scan(&logs) + + //v1 接口的历史日志数据兼容处理 + // 应用层处理:为历史数据(type=task 且 name 为空)补充任务名称 + fillTaskNamesForLogs(&logs) return logs, nil } +// fillTaskNamesForLogs 为历史日志数据补充任务名称 +func fillTaskNamesForLogs(logs *[]LogsResult) { + if logs == nil || len(*logs) == 0 { + return + } + + // 收集需要查询的 task_id + taskIdsMap := make(map[string]bool) + for _, log := range *logs { + // 只处理 type=task 且 name 为空的记录 + if (log.Type == "" || log.Type == "task") && log.Name == "" && log.TaskID != "" { + taskIdsMap[log.TaskID] = true + } + } + + // 如果没有需要查询的任务,直接返回 + if len(taskIdsMap) == 0 { + return + } + + // 批量查询任务名称 + taskIds := make([]string, 0, len(taskIdsMap)) + for taskId := range taskIdsMap { + taskIds = append(taskIds, taskId) + } + + var tasks []SendTasks + taskt := GetSchema(SendTasks{}) + db.Table(taskt). + Select("id, name"). + Where("id IN ?", taskIds). + Scan(&tasks) + + // 构建 taskId -> name 的映射 + taskNameMap := make(map[string]string) + for _, task := range tasks { + taskNameMap[task.ID] = task.Name + } + + // 填充日志的 name 字段 + for i := range *logs { + log := &(*logs)[i] + if (log.Type == "" || log.Type == "task") && log.Name == "" && log.TaskID != "" { + if taskName, exists := taskNameMap[log.TaskID]; exists { + log.Name = taskName + } + } + } +} + // GetSendLogsTotal 获取所有日志总数 func GetSendLogsTotal(name string, taskId string, maps map[string]interface{}) (int64, error) { var total int64 logt := GetSchema(SendTasksLogs{}) - taskt := GetSchema(SendTasks{}) - query := db. - Table(logt). - Joins(fmt.Sprintf("LEFT JOIN %s ON %s.task_id = %s.id", taskt, logt, taskt)) + + // 简化查询,只查询日志表 + query := db.Table(logt) dayVal, ok := maps["day_created_on"] if ok { @@ -86,8 +141,10 @@ func GetSendLogsTotal(name string, taskId string, maps map[string]interface{}) ( } query = query.Where(maps) + + // 按名称搜索(搜索日志表的 name 字段) if name != "" { - query = query.Where(fmt.Sprintf("%s.name like ?", taskt), fmt.Sprintf("%%%s%%", name)) + query = query.Where(fmt.Sprintf("%s.name like ?", logt), fmt.Sprintf("%%%s%%", name)) } if taskId != "" { query = query.Where(fmt.Sprintf("%s.task_id = ?", logt), taskId) diff --git a/routers/api/v1/message_template.go b/routers/api/v1/message_template.go new file mode 100644 index 0000000..759bbb2 --- /dev/null +++ b/routers/api/v1/message_template.go @@ -0,0 +1,336 @@ +package v1 + +import ( + "fmt" + "github.com/gin-gonic/gin" + "message-nest/models" + "message-nest/pkg/app" + "message-nest/pkg/e" + "message-nest/pkg/util" + "message-nest/service/message_template_service" + "message-nest/service/send_ins_service" + "net/http" +) + +// GetMessageTemplateList 获取消息模板列表 +func GetMessageTemplateList(c *gin.Context) { + appG := app.Gin{C: c} + text := c.Query("text") + status := c.Query("status") + + offset, limit := util.GetPageSize(c) + templateService := message_template_service.MessageTemplateService{ + Text: text, + Status: status, + PageNum: offset, + PageSize: limit, + } + + templates, err := templateService.GetAll() + if err != nil { + appG.CResponse(http.StatusInternalServerError, "获取消息模板失败!", nil) + return + } + + count, err := templateService.Count() + if err != nil { + appG.CResponse(http.StatusInternalServerError, "获取消息模板总数失败!", nil) + return + } + + appG.CResponse(http.StatusOK, "获取消息模板成功", map[string]interface{}{ + "lists": templates, + "total": count, + }) +} + +// GetMessageTemplate 获取单个消息模板 +func GetMessageTemplate(c *gin.Context) { + appG := app.Gin{C: c} + id := c.Query("id") + + templateService := message_template_service.MessageTemplateService{ + ID: id, + } + + exists, err := templateService.ExistByID() + if err != nil { + appG.CResponse(http.StatusInternalServerError, "查询模板失败!", nil) + return + } + + if !exists { + appG.CResponse(http.StatusNotFound, "模板不存在!", nil) + return + } + + template, err := templateService.Get() + if err != nil { + appG.CResponse(http.StatusInternalServerError, "获取模板详情失败!", nil) + return + } + + appG.CResponse(http.StatusOK, "获取模板详情成功", template) +} + +// AddMessageTemplate 添加消息模板 +func AddMessageTemplate(c *gin.Context) { + appG := app.Gin{C: c} + + var req struct { + Name string `json:"name" binding:"required"` + Description string `json:"description"` + TextTemplate string `json:"text_template"` + HTMLTemplate string `json:"html_template"` + MarkdownTemplate string `json:"markdown_template"` + Placeholders string `json:"placeholders"` + AtMobiles string `json:"at_mobiles"` + AtUserIds string `json:"at_user_ids"` + IsAtAll bool `json:"is_at_all"` + Status string `json:"status"` + } + + if err := c.ShouldBindJSON(&req); err != nil { + appG.CResponse(http.StatusBadRequest, "参数错误:"+err.Error(), nil) + return + } + + if req.TextTemplate == "" && req.HTMLTemplate == "" && req.MarkdownTemplate == "" { + appG.CResponse(http.StatusBadRequest, "至少需要填写一种格式的模板内容", nil) + return + } + + if req.Status == "" { + req.Status = "enabled" + } + + templateService := message_template_service.MessageTemplateService{ + Name: req.Name, + Description: req.Description, + TextTemplate: req.TextTemplate, + HTMLTemplate: req.HTMLTemplate, + MarkdownTemplate: req.MarkdownTemplate, + Placeholders: req.Placeholders, + AtMobiles: req.AtMobiles, + AtUserIds: req.AtUserIds, + IsAtAll: req.IsAtAll, + Status: req.Status, + } + + if err := templateService.Add(); err != nil { + appG.CResponse(http.StatusInternalServerError, "添加模板失败:"+err.Error(), nil) + return + } + + appG.CResponse(http.StatusOK, "添加模板成功", nil) +} + +// EditMessageTemplate 编辑消息模板 +func EditMessageTemplate(c *gin.Context) { + appG := app.Gin{C: c} + + var req struct { + ID string `json:"id" binding:"required"` + Name string `json:"name" binding:"required"` + Description string `json:"description"` + TextTemplate string `json:"text_template"` + HTMLTemplate string `json:"html_template"` + MarkdownTemplate string `json:"markdown_template"` + Placeholders string `json:"placeholders"` + AtMobiles string `json:"at_mobiles"` + AtUserIds string `json:"at_user_ids"` + IsAtAll bool `json:"is_at_all"` + Status string `json:"status"` + } + + if err := c.ShouldBindJSON(&req); err != nil { + appG.CResponse(http.StatusBadRequest, "参数错误:"+err.Error(), nil) + return + } + + if req.TextTemplate == "" && req.HTMLTemplate == "" && req.MarkdownTemplate == "" { + appG.CResponse(http.StatusBadRequest, "至少需要填写一种格式的模板内容", nil) + return + } + + templateService := message_template_service.MessageTemplateService{ + ID: req.ID, + } + + exists, err := templateService.ExistByID() + if err != nil { + appG.CResponse(http.StatusInternalServerError, "查询模板失败!", nil) + return + } + + if !exists { + appG.CResponse(http.StatusNotFound, "模板不存在!", nil) + return + } + + templateService.Name = req.Name + templateService.Description = req.Description + templateService.TextTemplate = req.TextTemplate + templateService.HTMLTemplate = req.HTMLTemplate + templateService.MarkdownTemplate = req.MarkdownTemplate + templateService.Placeholders = req.Placeholders + templateService.AtMobiles = req.AtMobiles + templateService.AtUserIds = req.AtUserIds + templateService.IsAtAll = req.IsAtAll + templateService.Status = req.Status + + if err := templateService.Update(); err != nil { + appG.CResponse(http.StatusInternalServerError, "更新模板失败:"+err.Error(), nil) + return + } + + appG.CResponse(http.StatusOK, "更新模板成功", nil) +} + +// DeleteMessageTemplate 删除消息模板 +func DeleteMessageTemplate(c *gin.Context) { + appG := app.Gin{C: c} + + var req struct { + ID string `json:"id" binding:"required"` + } + + if err := c.ShouldBindJSON(&req); err != nil { + appG.CResponse(http.StatusBadRequest, "参数错误:"+err.Error(), nil) + return + } + + templateService := message_template_service.MessageTemplateService{ + ID: req.ID, + } + + exists, err := templateService.ExistByID() + if err != nil { + appG.CResponse(http.StatusInternalServerError, "查询模板失败!", nil) + return + } + + if !exists { + appG.CResponse(http.StatusNotFound, "模板不存在!", nil) + return + } + + if err := templateService.Delete(); err != nil { + appG.CResponse(http.StatusInternalServerError, "删除模板失败:"+err.Error(), nil) + return + } + + appG.CResponse(http.StatusOK, "删除模板成功", nil) +} + +// PreviewMessageTemplate 预览消息模板 +func PreviewMessageTemplate(c *gin.Context) { + appG := app.Gin{C: c} + + var req struct { + ID string `json:"id" binding:"required"` + Params map[string]string `json:"params"` + } + + if err := c.ShouldBindJSON(&req); err != nil { + appG.CResponse(http.StatusBadRequest, "参数错误:"+err.Error(), nil) + return + } + + templateService := message_template_service.MessageTemplateService{ + ID: req.ID, + } + + exists, err := templateService.ExistByID() + if err != nil { + appG.CResponse(http.StatusInternalServerError, "查询模板失败!", nil) + return + } + + if !exists { + appG.CResponse(http.StatusNotFound, "模板不存在!", nil) + return + } + + preview, err := templateService.PreviewTemplate(req.Params) + if err != nil { + appG.CResponse(http.StatusInternalServerError, "预览模板失败:"+err.Error(), nil) + return + } + + appG.CResponse(http.StatusOK, "预览模板成功", preview) +} + +// GetTemplateWithIns 获取模板及其关联的实例 +func GetTemplateWithIns(c *gin.Context) { + appG := app.Gin{C: c} + id := c.Query("id") + + if id == "" { + appG.CResponse(http.StatusBadRequest, "模板ID为空!", nil) + return + } + + // 获取模板信息 + template, err := models.GetMessageTemplateByID(id) + if err != nil { + appG.CResponse(http.StatusBadRequest, "获取模板信息失败!", nil) + return + } + + // 获取关联的实例列表 + insList, err := models.GetTemplateInsList(id) + if err != nil { + appG.CResponse(http.StatusBadRequest, "获取实例列表失败!", nil) + return + } + + result := map[string]interface{}{ + "template": template, + "ins_list": insList, + } + + appG.CResponse(http.StatusOK, "获取模板信息成功", result) +} + +// TemplateInsReq 模板实例请求结构 +type TemplateInsReq struct { + ID string `json:"id" validate:"required,len=12" label:"实例id"` + TemplateID string `json:"template_id" validate:"required" label:"模板id"` + WayID string `json:"way_id" validate:"required,len=12" label:"渠道id"` + ContentType string `json:"content_type" validate:"required,max=100" label:"实例内容类型"` + Config string `json:"config" validate:"" label:"任务配置"` + Extra string `json:"extra" validate:"" label:"任务额外信息"` + WayType string `json:"way_type" validate:"required,max=100" label:"渠道类型"` +} + +// AddTemplateIns 添加模板关联的实例 +func AddTemplateIns(c *gin.Context) { + var ( + appG = app.Gin{C: c} + req TemplateInsReq + ) + + errCode, errStr := app.BindJsonAndPlayValid(c, &req) + if errCode != e.SUCCESS { + appG.CResponse(errCode, errStr, nil) + return + } + + sendTaskInsService := send_ins_service.SendTaskInsService{} + err := sendTaskInsService.AddOne(models.SendTasksIns{ + UUIDModel: models.UUIDModel{ID: req.ID}, + TemplateID: req.TemplateID, + WayID: req.WayID, + WayType: req.WayType, + ContentType: req.ContentType, + Config: req.Config, + Extra: req.Extra, + }) + if err != "" { + appG.CResponse(http.StatusBadRequest, fmt.Sprintf("添加实例失败!错误原因:%s", err), nil) + return + } + + appG.CResponse(http.StatusOK, "添加实例成功!", nil) +} diff --git a/routers/api/v1/send_message.go b/routers/api/v1/send_message.go index d4e3ee7..d78e3a3 100644 --- a/routers/api/v1/send_message.go +++ b/routers/api/v1/send_message.go @@ -61,6 +61,7 @@ func DoSendMassage(c *gin.Context) { } msgService := send_message_service.SendMessageService{ + SendMode: send_message_service.SendModeTask, // 明确标记为任务模式 TaskID: taskID, Title: req.Title, Text: req.Text, diff --git a/routers/api/v2/send_message.go b/routers/api/v2/send_message.go new file mode 100644 index 0000000..6553437 --- /dev/null +++ b/routers/api/v2/send_message.go @@ -0,0 +1,146 @@ +package v2 + +import ( + "fmt" + "message-nest/models" + "message-nest/pkg/app" + "message-nest/pkg/e" + utilpkg "message-nest/pkg/util" + "message-nest/service/send_message_service" + "net/http" + "strings" + + "github.com/gin-gonic/gin" + "github.com/sirupsen/logrus" +) + +type SendMessageByTemplateReq struct { + Token string `json:"token" validate:"required" label:"模板token"` + Title string `json:"title" validate:"required" label:"消息标题"` + Placeholders map[string]interface{} `json:"placeholders" label:"占位符"` +} + +// DoSendMessageByTemplate 使用模板发送消息 +func DoSendMessageByTemplate(c *gin.Context) { + var ( + appG = app.Gin{C: c} + req SendMessageByTemplateReq + ) + + errCode, errMsg := app.BindJsonAndPlayValid(c, &req) + if errCode != e.SUCCESS { + appG.CResponse(errCode, errMsg, nil) + return + } + + // 解析 token 为模板 ID + templateID, err := utilpkg.DecryptTokenHex(req.Token, 71) // 71 为简单对称密钥 + if err != nil { + appG.CResponse(http.StatusBadRequest, fmt.Sprintf("token解析失败:%v", err), nil) + return + } + + // 获取模板 + template, err := models.GetMessageTemplateByID(templateID) + if err != nil { + appG.CResponse(http.StatusBadRequest, fmt.Sprintf("模板不存在:%s", err), nil) + return + } + + // 检查模板状态 + if template.Status != "enabled" { + appG.CResponse(http.StatusBadRequest, "模板已禁用", nil) + return + } + + // 替换占位符 + textContent := replacePlaceholders(template.TextTemplate, req.Placeholders) + htmlContent := replacePlaceholders(template.HTMLTemplate, req.Placeholders) + markdownContent := replacePlaceholders(template.MarkdownTemplate, req.Placeholders) + + // 解析@提醒配置 + var atMobiles []string + var atUserIds []string + if template.AtMobiles != "" { + atMobiles = strings.Split(template.AtMobiles, ",") + // 去除空格 + for i := range atMobiles { + atMobiles[i] = strings.TrimSpace(atMobiles[i]) + } + } + if template.AtUserIds != "" { + atUserIds = strings.Split(template.AtUserIds, ",") + // 去除空格 + for i := range atUserIds { + atUserIds[i] = strings.TrimSpace(atUserIds[i]) + } + } + + // 获取模板关联的实例列表 + insList, err := models.GetTemplateInsList(templateID) + if err != nil || len(insList) == 0 { + appG.CResponse(http.StatusBadRequest, "模板没有配置发送实例", nil) + return + } + + // 过滤启用的实例 + var enabledCount int + for _, ins := range insList { + if ins.Enable == 1 { + enabledCount++ + } + } + + if enabledCount == 0 { + appG.CResponse(http.StatusBadRequest, "模板没有启用的发送实例", nil) + return + } + + // 使用发送服务进行发送 + // 将模板ID作为TaskID传入,用于日志记录 + msgService := send_message_service.SendMessageService{ + SendMode: send_message_service.SendModeTemplate, // 明确标记为模板模式 + TaskID: templateID, // 使用模板ID作为TaskID(用于日志记录) + TemplateID: templateID, // 模板ID + Name: template.Name, // 模板名称 + Title: req.Title, + Text: textContent, + HTML: htmlContent, + MarkDown: markdownContent, + CallerIp: c.ClientIP(), + AtMobiles: atMobiles, + AtUserIds: atUserIds, + AtAll: template.IsAtAll, + DefaultLogger: logrus.WithFields(logrus.Fields{ + "prefix": "[Template Send]", + }), + } + + // 发送前检查 + task, err := msgService.SendPreCheck() + if err != nil { + appG.CResponse(http.StatusBadRequest, fmt.Sprintf("发送检查不通过:%s", err), nil) + return + } + + // 异步发送 + msgService.AsyncSend(task) + appG.CResponse(http.StatusOK, "success", map[string]interface{}{ + "token": req.Token, + "count": enabledCount, + }) +} + +// replacePlaceholders 替换模板中的占位符 +func replacePlaceholders(template string, placeholders map[string]interface{}) string { + if template == "" || placeholders == nil { + return template + } + + result := template + for key, value := range placeholders { + placeholder := fmt.Sprintf("{{%s}}", key) + result = strings.ReplaceAll(result, placeholder, fmt.Sprintf("%v", value)) + } + return result +} diff --git a/routers/router.go b/routers/router.go index 66d3790..08fe9fe 100644 --- a/routers/router.go +++ b/routers/router.go @@ -8,6 +8,7 @@ import ( "message-nest/pkg/setting" "message-nest/routers/api" "message-nest/routers/api/v1" + "message-nest/routers/api/v2" "net/http" ) @@ -98,6 +99,26 @@ func InitRouter(f embed.FS) *gin.Engine { // hostedMessage apiV1.GET("/hostedmessages/list", v1.GetHostMessageList) + // messageTemplate + apiV1.GET("/templates/list", v1.GetMessageTemplateList) + apiV1.GET("/templates/get", v1.GetMessageTemplate) + apiV1.POST("/templates/add", v1.AddMessageTemplate) + apiV1.POST("/templates/edit", v1.EditMessageTemplate) + apiV1.POST("/templates/delete", v1.DeleteMessageTemplate) + apiV1.POST("/templates/preview", v1.PreviewMessageTemplate) + + // messageTemplate instances + apiV1.GET("/templates/ins/get", v1.GetTemplateWithIns) + apiV1.POST("/templates/ins/addone", v1.AddTemplateIns) + + } + + // API v2 + apiV2 := app.Group("/api/v2") + apiV2.Use(middleware.JWT()) + { + // message/send - 使用模板发送消息 + apiV2.POST("/message/send", v2.DoSendMessageByTemplate) } return app diff --git a/service/message_template_service/message_template.go b/service/message_template_service/message_template.go new file mode 100644 index 0000000..2290f52 --- /dev/null +++ b/service/message_template_service/message_template.go @@ -0,0 +1,185 @@ +package message_template_service + +import ( + "encoding/json" + "errors" + "message-nest/models" + "strings" +) + +type MessageTemplateService struct { + ID string + Name string + Description string + TextTemplate string + HTMLTemplate string + MarkdownTemplate string + Placeholders string + AtMobiles string + AtUserIds string + IsAtAll bool + Status string + Text string + + PageNum int + PageSize int +} + +// Placeholder 占位符定义 +type Placeholder struct { + Key string `json:"key"` + Label string `json:"label"` + Default string `json:"default"` +} + +// Add 添加消息模板 +func (s *MessageTemplateService) Add() error { + if err := s.validatePlaceholders(); err != nil { + return err + } + + newUUID := models.GenerateTemplateUniqueID() + model := models.MessageTemplate{ + UUIDModel: models.UUIDModel{ + ID: newUUID, + }, + Name: s.Name, + Description: s.Description, + TextTemplate: s.TextTemplate, + HTMLTemplate: s.HTMLTemplate, + MarkdownTemplate: s.MarkdownTemplate, + Placeholders: s.Placeholders, + AtMobiles: s.AtMobiles, + AtUserIds: s.AtUserIds, + IsAtAll: s.IsAtAll, + Status: s.Status, + } + + return model.Add() +} + +// Update 更新消息模板 +func (s *MessageTemplateService) Update() error { + if err := s.validatePlaceholders(); err != nil { + return err + } + + model := models.MessageTemplate{ + UUIDModel: models.UUIDModel{ + ID: s.ID, + }, + Name: s.Name, + Description: s.Description, + TextTemplate: s.TextTemplate, + HTMLTemplate: s.HTMLTemplate, + MarkdownTemplate: s.MarkdownTemplate, + Placeholders: s.Placeholders, + AtMobiles: s.AtMobiles, + AtUserIds: s.AtUserIds, + IsAtAll: s.IsAtAll, + Status: s.Status, + } + + return model.Update() +} + +// Delete 删除消息模板 +func (s *MessageTemplateService) Delete() error { + model := models.MessageTemplate{ + UUIDModel: models.UUIDModel{ + ID: s.ID, + }, + } + return model.Delete() +} + +// Get 获取单个消息模板 +func (s *MessageTemplateService) Get() (*models.MessageTemplateResult, error) { + return models.GetMessageTemplateByID(s.ID) +} + +// GetAll 获取消息模板列表 +func (s *MessageTemplateService) GetAll() ([]models.MessageTemplateResult, error) { + templates, err := models.GetMessageTemplates(s.PageNum, s.PageSize, s.Text, s.getMaps()) + if err != nil { + return nil, err + } + return templates, nil +} + +// Count 获取消息模板总数 +func (s *MessageTemplateService) Count() (int64, error) { + return models.GetMessageTemplatesTotal(s.Text, s.getMaps()) +} + +// ExistByID 检查模板是否存在 +func (s *MessageTemplateService) ExistByID() (bool, error) { + return models.ExistMessageTemplateByID(s.ID) +} + +// RenderTemplate 渲染模板(替换占位符) +func (s *MessageTemplateService) RenderTemplate(templateContent string, params map[string]string) string { + result := templateContent + + for key, value := range params { + placeholder := "{{" + key + "}}" + result = strings.ReplaceAll(result, placeholder, value) + } + + return result +} + +// PreviewTemplate 预览模板效果 +func (s *MessageTemplateService) PreviewTemplate(params map[string]string) (map[string]string, error) { + template, err := s.Get() + if err != nil { + return nil, err + } + + result := make(map[string]string) + + if template.TextTemplate != "" { + result["text"] = s.RenderTemplate(template.TextTemplate, params) + } + + if template.HTMLTemplate != "" { + result["html"] = s.RenderTemplate(template.HTMLTemplate, params) + } + + if template.MarkdownTemplate != "" { + result["markdown"] = s.RenderTemplate(template.MarkdownTemplate, params) + } + + return result, nil +} + +// validatePlaceholders 验证占位符格式 +func (s *MessageTemplateService) validatePlaceholders() error { + if s.Placeholders == "" { + return nil + } + + var placeholders []Placeholder + if err := json.Unmarshal([]byte(s.Placeholders), &placeholders); err != nil { + return errors.New("占位符格式错误,必须是有效的JSON数组") + } + + for _, p := range placeholders { + if p.Key == "" { + return errors.New("占位符的key不能为空") + } + } + + return nil +} + +// getMaps 获取查询条件 +func (s *MessageTemplateService) getMaps() map[string]interface{} { + maps := make(map[string]interface{}) + + if s.Status != "" { + maps["status"] = s.Status + } + + return maps +} diff --git a/service/send_message_service/send_message.go b/service/send_message_service/send_message.go index 1874ff6..e4b27e3 100644 --- a/service/send_message_service/send_message.go +++ b/service/send_message_service/send_message.go @@ -18,6 +18,12 @@ const ( SendFail = 0 ) +// 发送模式类型 +const ( + SendModeTask = "task" // 传统任务模式 + SendModeTemplate = "template" // 模板模式 +) + func errStrIsSuccess(errStr string) int { if errStr == "" { return SendSuccess @@ -26,13 +32,16 @@ func errStrIsSuccess(errStr string) int { } type SendMessageService struct { - TaskID string - Title string - Text string - HTML string - URL string - MarkDown string - CallerIp string + SendMode string // 发送模式:task(任务模式) 或 template(模板模式) + TaskID string // 任务ID(任务模式)或模板ID(模板模式,用于日志记录) + TemplateID string // 模板ID(仅模板模式使用) + Name string // 任务或模板名称(用于日志记录) + Title string + Text string + HTML string + URL string + MarkDown string + CallerIp string // @提及相关字段 AtMobiles []string @@ -82,31 +91,88 @@ func (sm *SendMessageService) AsyncSend(task models.TaskIns) { } // SendPreCheck 发送前数据准备和预检查 +// 支持两种模式: +// 1. SendModeTask:传统任务模式,使用 TaskID 查询任务和实例 +// 2. SendModeTemplate:模板模式,使用 TemplateID 查询模板关联的实例 func (sm *SendMessageService) SendPreCheck() (models.TaskIns, error) { errStr := "" entry := logrus.WithFields(logrus.Fields{ "prefix": "[Message PreChecK]", }) - sendTaskService := send_task_service.SendTaskService{ - ID: sm.TaskID, - } - task, err := sendTaskService.GetTaskWithIns() - if err != nil { - errStr = fmt.Sprintf("任务[%s]查询失败!", sm.TaskID) + + var task models.TaskIns + + switch sm.SendMode { + case SendModeTemplate: + // 模板模式:使用模板ID获取实例 + if sm.TemplateID == "" { + errStr = "模板模式下 TemplateID 不能为空" + entry.Errorf(errStr) + return task, errors.New(errStr) + } + + // 获取模板关联的实例列表 + insList, err := models.GetTemplateInsList(sm.TemplateID) + if err != nil { + errStr = fmt.Sprintf("模板[%s]实例查询失败:%s", sm.TemplateID, err) + entry.Errorf(errStr) + return task, errors.New(errStr) + } + if len(insList) == 0 { + errStr = fmt.Sprintf("模板[%s]没有关联任何实例!", sm.TemplateID) + entry.Errorf(errStr) + return task, errors.New(errStr) + } + + // 构造虚拟任务对象(用于兼容现有发送逻辑) + // 将模板ID作为TaskID使用,便于日志记录 + task.ID = sm.TaskID // 使用传入的TaskID(实际是模板ID) + task.InsData = make([]models.SendTasksInsRes, 0, len(insList)) + for _, ins := range insList { + task.InsData = append(task.InsData, ins) + } + entry.Infof("模板[%s]加载了 %d 个实例", sm.TemplateID, len(insList)) + return task, nil + + case SendModeTask: + // 传统任务模式:使用任务ID查询 + if sm.TaskID == "" { + errStr = "任务模式下 TaskID 不能为空" + entry.Errorf(errStr) + return task, errors.New(errStr) + } + + sendTaskService := send_task_service.SendTaskService{ + ID: sm.TaskID, + } + task, err := sendTaskService.GetTaskWithIns() + if err != nil { + errStr = fmt.Sprintf("任务[%s]查询失败!", sm.TaskID) + entry.Errorf(errStr) + return task, errors.New(errStr) + } + if task.ID == "" { + errStr = fmt.Sprintf("任务[%s]不存在!", sm.TaskID) + entry.Errorf(errStr) + return task, errors.New(errStr) + } + if len(task.InsData) == 0 { + errStr = fmt.Sprintf("任务[%s]没有关联任何实例!!", sm.TaskID) + entry.Errorf(errStr) + return task, errors.New(errStr) + } + // 设置任务名称用于日志记录 + if sm.Name == "" { + sm.Name = task.Name + } + return task, nil + + default: + // SendMode 未设置或无效 + errStr = fmt.Sprintf("SendMode 未设置或无效: %s,必须是 '%s' 或 '%s'", sm.SendMode, SendModeTask, SendModeTemplate) entry.Errorf(errStr) return task, errors.New(errStr) } - if task.ID == "" { - errStr = fmt.Sprintf("任务[%s]不存在!", sm.TaskID) - entry.Errorf(errStr) - return task, errors.New(errStr) - } - if len(task.InsData) == 0 { - errStr = fmt.Sprintf("任务[%s]没有关联任何实例!!", sm.TaskID) - entry.Errorf(errStr) - return task, errors.New(errStr) - } - return task, nil } // Send 发送一个消息任务的所有实例 @@ -143,13 +209,6 @@ func (sm *SendMessageService) Send(task models.TaskIns) (string, error) { sm.LogsAndStatusMark(fmt.Sprintf("实例类型: %s + %s", ins.WayType, ins.ContentType), sm.Status) sm.LogsAndStatusMark(fmt.Sprintf("实例配置: %s", ins.Config), sm.Status) - // 发送内容校验绑定 - typeC, content := sm.GetSendMsg(ins.SendTasksIns) - if content == "" { - sm.LogsAndStatusMark(fmt.Sprintf("发送内容为空,设置的类型: %s,实际检测的类型: %s", ins.SendTasksIns.ContentType, typeC), SendFail) - continue - } - // 发送渠道的校验 errStr, msgObj := wayService.ValidateDiffWay() if errStr != "" { @@ -165,16 +224,33 @@ func (sm *SendMessageService) Send(task models.TaskIns) (string, error) { continue } - // 构建统一消息内容(支持@功能) - unifiedContent := &unified.UnifiedMessageContent{ - Title: sm.Title, - Text: sm.Text, - HTML: sm.HTML, - Markdown: sm.MarkDown, - URL: sm.URL, - AtMobiles: sm.AtMobiles, - AtUserIds: sm.AtUserIds, - AtAll: sm.AtAll, + // 根据发送模式构建消息内容 + var unifiedContent *unified.UnifiedMessageContent + if sm.SendMode == SendModeTemplate { + // 模板模式:根据实例的 ContentType 精确发送对应类型的内容 + unifiedContent = sm.BuildTemplateContent(ins.SendTasksIns) + if unifiedContent == nil { + sm.LogsAndStatusMark(fmt.Sprintf("模板内容为空,实例类型: %s", ins.ContentType), SendFail) + continue + } + } else { + // 任务模式:使用现有逻辑(支持内容类型回退) + typeC, content := sm.GetSendMsg(ins.SendTasksIns) + if content == "" { + sm.LogsAndStatusMark(fmt.Sprintf("发送内容为空,设置的类型: %s,实际检测的类型: %s", ins.SendTasksIns.ContentType, typeC), SendFail) + continue + } + // 构建统一消息内容(支持@功能) + unifiedContent = &unified.UnifiedMessageContent{ + Title: sm.Title, + Text: sm.Text, + HTML: sm.HTML, + Markdown: sm.MarkDown, + URL: sm.URL, + AtMobiles: sm.AtMobiles, + AtUserIds: sm.AtUserIds, + AtAll: sm.AtAll, + } } // 使用 SendUnified 方法(自动格式转换和@功能支持) @@ -216,9 +292,17 @@ func (sm *SendMessageService) AppendSendContent() { // RecordSendLog 记录发送日志 func (sm *SendMessageService) RecordSendLog() { + // 确定日志类型 + logType := "task" + if sm.SendMode == SendModeTemplate { + logType = "template" + } + log := models.SendTasksLogs{ Log: strings.Join(sm.LogOutput, "\n"), TaskID: sm.TaskID, + Type: logType, + Name: sm.Name, Status: &sm.Status, CallerIp: sm.CallerIp, } @@ -237,7 +321,52 @@ func (sm *SendMessageService) TransError(err string) string { } } -// GetSendMsg 获取对应消息内容 +// BuildTemplateContent 构建模板模式的消息内容 +// 模板模式:根据实例的 ContentType 精确匹配对应类型的内容,只传递该类型的内容 +func (sm *SendMessageService) BuildTemplateContent(ins models.SendTasksIns) *unified.UnifiedMessageContent { + contentType := strings.ToLower(ins.ContentType) + + // 内容类型映射表 + contentMap := map[string]string{ + unified.FormatTypeText: sm.Text, + unified.FormatTypeHTML: sm.HTML, + unified.FormatTypeMarkdown: sm.MarkDown, + } + + // 检查内容是否存在 + contentValue, exists := contentMap[contentType] + if !exists { + logrus.Warnf("模板模式:未知的内容类型 %s", ins.ContentType) + return nil + } + if contentValue == "" { + logrus.Warnf("模板模式:实例要求的 %s 类型内容为空", contentType) + return nil + } + + // 构建消息内容,只填充实例要求的类型 + content := &unified.UnifiedMessageContent{ + Title: sm.Title, + URL: sm.URL, + AtMobiles: sm.AtMobiles, + AtUserIds: sm.AtUserIds, + AtAll: sm.AtAll, + } + + // 根据类型填充对应字段 + switch contentType { + case unified.FormatTypeText: + content.Text = contentValue + case unified.FormatTypeHTML: + content.HTML = contentValue + case unified.FormatTypeMarkdown: + content.Markdown = contentValue + } + + return content +} + +// GetSendMsg 获取对应消息内容(任务模式使用) // 先根据实例设置的类型取,取不到或者取到的是空,则使用text发送 func (sm *SendMessageService) GetSendMsg(ins models.SendTasksIns) (string, string) { data := map[string]string{} diff --git a/web/src/components/Index.vue b/web/src/components/Index.vue index 4b896bf..8e6ffbc 100644 --- a/web/src/components/Index.vue +++ b/web/src/components/Index.vue @@ -252,6 +252,7 @@ const tabRoutes: TabRoute[] = [ { name: '发信日志', path: '/sendlogs' }, { name: '托管消息', path: '/hostedmessage' }, { name: '定时消息', path: '/cronmessages' }, + { name: '模板任务', path: '/templates' }, { name: '发信任务', path: '/sendtasks' }, { name: '发信渠道', path: '/sendways' }, { name: '设置偏好', path: '/settings' } @@ -326,9 +327,9 @@ const siteTitle = computed(() => {
-