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(() => { -
💡 提示:@功能仅钉钉和企业微信支持
+💡 提示:@功能仅钉钉和企业微信支持
+📋 发送顺序:实例配置的内容类型优先,若为空则按 HTML → Markdown → Text 顺序回退