feat: message notify

This commit is contained in:
meilin.huang
2025-04-15 21:42:31 +08:00
parent 3c0292b56e
commit 1b40d345eb
104 changed files with 2681 additions and 288 deletions

View File

@@ -30,4 +30,5 @@ const (
ImsgNumDb = 60000
ImsgNumRedis = 70000
ImsgNumMongo = 80000
ImsgNumMsg = 90000
)

View File

@@ -226,9 +226,10 @@ var (
SQLValue: SQLValueNumeric,
}
// 使用string进行转换避免长度过长导致精度丢失等
DTNumeric = &DataType{
Name: "numeric",
Valuer: ValuerFloat64,
Valuer: ValuerString,
SQLValue: SQLValueNumeric,
}

View File

@@ -97,6 +97,7 @@ func (c *commonTypeConverter) Blob(col *dbi.Column) *dbi.DbDataType {
return Blob
}
func (c *commonTypeConverter) Longblob(col *dbi.Column) *dbi.DbDataType {
col.CharMaxLength = 0
return Longblob
}

View File

@@ -1,6 +1,6 @@
package event
const (
EventTopicDeleteMachine = "machine:delete" // 删除机器的事件主题
EventTopicResourceOp = "resource:op" // 资源操作主题
EventTopicResourceOp = "resource:op" // 资源操作主题
EventTopicBizMsgTmplSend = "biz:msgtmpl:send" // 发送业务关联的消息模板
)

View File

@@ -10,6 +10,7 @@ type Procdef struct {
Status entity.ProcdefStatus `json:"status" binding:"required"`
Condition string `json:"condition"`
Remark string `json:"remark"`
MsgTmplId uint64 `json:"msgTmplId"`
CodePaths []string `json:"codePaths"`
}

View File

@@ -7,11 +7,14 @@ import (
"mayfly-go/internal/flow/application/dto"
"mayfly-go/internal/flow/domain/entity"
"mayfly-go/internal/flow/imsg"
msgapp "mayfly-go/internal/msg/application"
msgentity "mayfly-go/internal/msg/domain/entity"
tagapp "mayfly-go/internal/tag/application"
tagentity "mayfly-go/internal/tag/domain/entity"
"mayfly-go/pkg/biz"
"mayfly-go/pkg/req"
"mayfly-go/pkg/utils/collx"
"mayfly-go/pkg/utils/structx"
"strings"
"github.com/may-fly/cast"
@@ -20,12 +23,15 @@ import (
type Procdef struct {
procdefApp application.Procdef `inject:"T"`
tagTreeRelateApp tagapp.TagTreeRelate `inject:"T"`
msgTmplBizApp msgapp.MsgTmplBiz `inject:"T"`
}
func (p *Procdef) ReqConfs() *req.Confs {
reqs := [...]*req.Conf{
req.NewGet("", p.GetProcdefPage),
req.NewGet("/detail/:id", p.GetProcdefDetail),
req.NewGet("/:resourceType/:resourceCode", p.GetProcdef),
req.NewPost("", p.Save).Log(req.NewLogSaveI(imsg.LogProcdefSave)).RequiredPermissionCode("flow:procdef:save"),
@@ -49,6 +55,25 @@ func (p *Procdef) GetProcdefPage(rc *req.Ctx) {
rc.ResData = res
}
func (p *Procdef) GetProcdefDetail(rc *req.Ctx) {
def, err := p.procdefApp.GetById(cast.ToUint64(rc.PathParamInt("id")))
biz.ErrIsNil(err)
res := new(vo.Procdef)
biz.ErrIsNil(structx.Copy(res, def))
p.tagTreeRelateApp.FillTagInfo(tagentity.TagRelateTypeFlowDef, res)
bizMsgTmpl := &msgentity.MsgTmplBiz{
BizId: res.Id,
BizType: application.FlowTaskNotifyBizKey,
}
if p.msgTmplBizApp.GetByCond(bizMsgTmpl) == nil {
res.MsgTmplId = &bizMsgTmpl.TmplId
}
rc.ResData = res
}
func (p *Procdef) GetProcdef(rc *req.Ctx) {
resourceType := rc.PathParamInt("resourceType")
resourceCode := rc.PathParam("resourceCode")
@@ -61,6 +86,7 @@ func (a *Procdef) Save(rc *req.Ctx) {
rc.ReqParam = form
biz.ErrIsNil(a.procdefApp.SaveProcdef(rc.MetaCtx, &dto.SaveProcdef{
Procdef: procdef,
MsgTmplId: form.MsgTmplId,
CodePaths: form.CodePaths,
}))
}

View File

@@ -8,6 +8,8 @@ import (
type Procdef struct {
tagentity.RelateTags // 标签信息
entity.Procdef
MsgTmplId *uint64 `json:"msgTmplId" gorm:"-"` // 消息模板ID
}
func (p *Procdef) GetRelateId() uint64 {

View File

@@ -0,0 +1,5 @@
package application
const (
FlowTaskNotifyBizKey = "flow:task:notify" // 工单任务处理通知
)

View File

@@ -4,6 +4,7 @@ import "mayfly-go/internal/flow/domain/entity"
type SaveProcdef struct {
Procdef *entity.Procdef
MsgTmplId uint64 // 消息模板id
CodePaths []string
}

View File

@@ -6,6 +6,8 @@ import (
"mayfly-go/internal/flow/domain/entity"
"mayfly-go/internal/flow/domain/repository"
"mayfly-go/internal/flow/imsg"
msgapp "mayfly-go/internal/msg/application"
msgdto "mayfly-go/internal/msg/application/dto"
tagapp "mayfly-go/internal/tag/application"
tagentity "mayfly-go/internal/tag/domain/entity"
"mayfly-go/pkg/base"
@@ -36,6 +38,7 @@ type procdefAppImpl struct {
procinstApp Procinst `inject:"T"`
msgTmplBizApp msgapp.MsgTmplBiz `inject:"T"`
tagTreeApp tagapp.TagTree `inject:"T"`
tagTreeRelateApp tagapp.TagTreeRelate `inject:"T"`
}
@@ -67,6 +70,14 @@ func (p *procdefAppImpl) SaveProcdef(ctx context.Context, defParam *dto.SaveProc
return p.Tx(ctx, func(ctx context.Context) error {
return p.Save(ctx, def)
}, func(ctx context.Context) error {
// 保存通知消息模板
if err := p.msgTmplBizApp.SaveBizTmpl(ctx, msgdto.MsgTmplBizSave{
TmplId: defParam.MsgTmplId,
BizType: FlowTaskNotifyBizKey,
BizId: def.Id,
}); err != nil {
return err
}
return p.tagTreeRelateApp.RelateTag(ctx, tagentity.TagRelateTypeFlowDef, def.Id, defParam.CodePaths...)
})
}

View File

@@ -3,19 +3,24 @@ package application
import (
"context"
"fmt"
"mayfly-go/internal/event"
"mayfly-go/internal/flow/application/dto"
"mayfly-go/internal/flow/domain/entity"
"mayfly-go/internal/flow/domain/repository"
"mayfly-go/internal/flow/imsg"
msgdto "mayfly-go/internal/msg/application/dto"
"mayfly-go/pkg/base"
"mayfly-go/pkg/contextx"
"mayfly-go/pkg/errorx"
"mayfly-go/pkg/global"
"mayfly-go/pkg/i18n"
"mayfly-go/pkg/logx"
"mayfly-go/pkg/model"
"mayfly-go/pkg/utils/anyx"
"mayfly-go/pkg/utils/jsonx"
"mayfly-go/pkg/utils/stringx"
"github.com/may-fly/cast"
)
type Procinst interface {
@@ -140,6 +145,7 @@ func (p *procinstAppImpl) CompleteTask(ctx context.Context, instTaskId uint64, r
procinst.SetEnd()
} else {
procinst.TaskKey = task.TaskKey
}
return p.Tx(ctx, func(ctx context.Context) error {
@@ -287,7 +293,26 @@ func (p *procinstAppImpl) createProcinstTask(ctx context.Context, procinst *enti
TaskName: task.Name,
Assignee: task.UserId,
}
return p.procinstTaskRepo.Insert(ctx, procinstTask)
if err := p.procinstTaskRepo.Insert(ctx, procinstTask); err != nil {
return err
}
// 发送通知消息
global.EventBus.Publish(ctx, event.EventTopicBizMsgTmplSend, msgdto.BizMsgTmplSend{
BizType: FlowTaskNotifyBizKey,
BizId: procinst.ProcdefId,
Params: map[string]any{
"creator": procinst.Creator,
"procdefName": procinst.ProcdefName,
"bizKey": procinst.BizKey,
"taskName": task.Name,
"procinstRemark": procinst.Remark,
},
ReceiverIds: []uint64{cast.ToUint64(task.UserId)},
})
return nil
}
// 获取下一审批节点任务

View File

@@ -15,6 +15,7 @@ type ProcinstTaskQuery struct {
ProcinstId uint64 `json:"procinstId"` // 流程实例id
ProcinstName string `json:"procinstName"` // 流程实例名称
BizType string `json:"bizType" form:"bizType"`
BizKey string `json:"bizKey" form:"bizKey"` // 业务key
Assignee string `json:"assignee"` // 分配到该任务的用户
Status ProcinstTaskStatus `json:"status" form:"status"` // 状态
}

View File

@@ -4,6 +4,7 @@ import (
"mayfly-go/internal/flow/domain/entity"
"mayfly-go/internal/flow/domain/repository"
"mayfly-go/pkg/base"
"mayfly-go/pkg/gormx"
"mayfly-go/pkg/model"
)
@@ -31,6 +32,14 @@ func newProcinstTaskRepo() repository.ProcinstTask {
}
func (p *procinstTaskImpl) GetPageList(condition *entity.ProcinstTaskQuery, pageParam *model.PageParam, toEntity any, orderBy ...string) (*model.PageResult[any], error) {
qd := model.NewModelCond(condition)
return p.PageByCondToAny(qd, pageParam, toEntity)
qd := gormx.NewQueryWithTableName("t_flow_procinst_task t").
Joins("JOIN t_flow_procinst tp ON t.procinst_id = tp.id ").
WithCond(model.NewCond().Columns("t.*, tp.biz_key").
Eq("tp.biz_key", condition.BizKey).
Eq0("tp.is_deleted", model.ModelUndeleted).
Eq("tp.biz_type", condition.BizType).
Eq0("t.is_deleted", model.ModelUndeleted).
Eq("t.status", condition.Status).
OrderByDesc("t.id"))
return gormx.PageQuery(qd, pageParam, toEntity)
}

View File

@@ -71,7 +71,9 @@ func (m *MachineScript) RunMachineScript(rc *req.Ctx) {
script := ms.Script
// 如果有脚本参数,则用脚本参数替换脚本中的模板占位符参数
if params := rc.Query("params"); params != "" {
script, err = stringx.TemplateParse(ms.Script, jsonx.ToMap(params))
p, err := jsonx.ToMap(params)
biz.ErrIsNil(err)
script, err = stringx.TemplateParse(ms.Script, p)
biz.ErrIsNilAppendErr(err, "failed to parse the script template parameter: %s")
}
cli, err := m.machineApp.GetCliByAc(ac)

View File

@@ -1,11 +1,6 @@
package application
import (
"context"
"mayfly-go/internal/event"
"mayfly-go/internal/machine/domain/entity"
"mayfly-go/pkg/eventbus"
"mayfly-go/pkg/global"
"mayfly-go/pkg/ioc"
"sync"
)
@@ -26,16 +21,6 @@ func Init() {
GetMachineApp().TimerUpdateStats()
GetMachineTermOpApp().TimerDeleteTermOp()
global.EventBus.Subscribe(event.EventTopicDeleteMachine, "machineFile", func(ctx context.Context, event *eventbus.Event) error {
me := event.Val.(*entity.Machine)
return GetMachineFileApp().DeleteByCond(ctx, &entity.MachineFile{MachineId: me.Id})
})
global.EventBus.Subscribe(event.EventTopicDeleteMachine, "machineScript", func(ctx context.Context, event *eventbus.Event) error {
me := event.Val.(*entity.Machine)
return GetMachineScriptApp().DeleteByCond(ctx, &entity.MachineScript{MachineId: me.Id})
})
})()
}

View File

@@ -3,7 +3,6 @@ package application
import (
"context"
"fmt"
"mayfly-go/internal/event"
"mayfly-go/internal/machine/application/dto"
"mayfly-go/internal/machine/domain/entity"
"mayfly-go/internal/machine/domain/repository"
@@ -15,7 +14,6 @@ import (
tagentity "mayfly-go/internal/tag/domain/entity"
"mayfly-go/pkg/base"
"mayfly-go/pkg/errorx"
"mayfly-go/pkg/global"
"mayfly-go/pkg/logx"
"mayfly-go/pkg/model"
"mayfly-go/pkg/scheduler"
@@ -65,6 +63,9 @@ type machineAppImpl struct {
tagApp tagapp.TagTree `inject:"T"`
resourceAuthCertApp tagapp.ResourceAuthCert `inject:"T"`
machineScriptApp MachineScript `inject:"T"`
machineFileApp MachineFile `inject:"T"`
}
var _ (Machine) = (*machineAppImpl)(nil)
@@ -198,12 +199,15 @@ func (m *machineAppImpl) Delete(ctx context.Context, id uint64) error {
// 关闭连接
mcm.DeleteCli(id)
// 发布机器删除事件
global.EventBus.Publish(ctx, event.EventTopicDeleteMachine, machine)
resourceType := tagentity.TagTypeMachine
return m.Tx(ctx,
func(ctx context.Context) error {
if err := m.machineFileApp.DeleteByCond(ctx, &entity.MachineFile{MachineId: id}); err != nil {
return err
}
if err := m.machineScriptApp.DeleteByCond(ctx, &entity.MachineScript{MachineId: id}); err != nil {
return err
}
return m.DeleteById(ctx, id)
}, func(ctx context.Context) error {
return m.tagApp.SaveResourceTag(ctx, &tagdto.SaveResourceTag{

View File

@@ -4,4 +4,6 @@ import "mayfly-go/pkg/ioc"
func InitIoc() {
ioc.Register(new(Msg))
ioc.Register(new(MsgChannel))
ioc.Register(new(MsgTmpl))
}

View File

@@ -0,0 +1,35 @@
package form
import (
"mayfly-go/internal/msg/msgx"
"mayfly-go/pkg/model"
)
type MsgChannel struct {
model.ExtraData
Id uint64 `json:"id"`
Name string `json:"name" binding:"required"`
Type string `json:"type" binding:"required"`
Url string `json:"url"`
Remark string `json:"remark"`
Status int8 `json:"status" binding:"required"`
}
type MsgTmpl struct {
model.ExtraData
Id uint64 `json:"id"`
Name string `json:"name" binding:"required"`
Title string `json:"title"`
Tmpl string `json:"tmpl" binding:"required"`
MsgType msgx.MsgType `json:"msgType" binding:"required"`
Remark string `json:"remark"`
Status int8 `json:"status" binding:"required"`
ChannelIds []uint64 `json:"channelIds"`
}
type SendMsg struct {
Parmas string `json:"params"`
ReceiverIds []uint64 `json:"receiverIds"`
}

View File

@@ -0,0 +1,54 @@
package api
import (
"mayfly-go/internal/msg/api/form"
"mayfly-go/internal/msg/application"
"mayfly-go/internal/msg/domain/entity"
"mayfly-go/internal/msg/imsg"
"mayfly-go/pkg/biz"
"mayfly-go/pkg/req"
"strings"
"github.com/may-fly/cast"
)
type MsgChannel struct {
msgChannelApp application.MsgChannel `inject:"T"`
}
func (m *MsgChannel) ReqConfs() *req.Confs {
basePermCode := "msg:channel:base"
reqs := [...]*req.Conf{
req.NewGet("", m.GetMsgChannels).RequiredPermissionCode(basePermCode),
req.NewPost("", m.SaveMsgChannels).Log(req.NewLogSaveI(imsg.LogMsgChannelSave)).RequiredPermissionCode("msg:channel:save"),
req.NewDelete("", m.DelMsgChannels).Log(req.NewLogSaveI(imsg.LogMsgChannelDelete)).RequiredPermissionCode("msg:channel:del"),
}
return req.NewConfs("/msg/channels", reqs[:]...)
}
func (m *MsgChannel) GetMsgChannels(rc *req.Ctx) {
condition := &entity.MsgChannel{}
res, err := m.msgChannelApp.GetPageList(condition, rc.GetPageParam(), new([]entity.MsgChannel))
biz.ErrIsNil(err)
rc.ResData = res
}
func (m *MsgChannel) SaveMsgChannels(rc *req.Ctx) {
form := &form.MsgChannel{}
rc.ReqParam = form
channel := req.BindJsonAndCopyTo(rc, form, new(entity.MsgChannel))
err := m.msgChannelApp.SaveChannel(rc.MetaCtx, channel)
biz.ErrIsNil(err)
}
func (m *MsgChannel) DelMsgChannels(rc *req.Ctx) {
idsStr := rc.Query("id")
rc.ReqParam = idsStr
ids := strings.Split(idsStr, ",")
for _, v := range ids {
biz.ErrIsNil(m.msgChannelApp.DeleteChannel(rc.MetaCtx, cast.ToUint64(v)))
}
}

View File

@@ -0,0 +1,86 @@
package api
import (
"mayfly-go/internal/msg/api/form"
"mayfly-go/internal/msg/application"
"mayfly-go/internal/msg/application/dto"
"mayfly-go/internal/msg/domain/entity"
"mayfly-go/internal/msg/imsg"
"mayfly-go/pkg/biz"
"mayfly-go/pkg/req"
"mayfly-go/pkg/utils/collx"
"mayfly-go/pkg/utils/jsonx"
"strings"
"github.com/may-fly/cast"
)
type MsgTmpl struct {
msgTmplApp application.MsgTmpl `inject:"T"`
}
func (m *MsgTmpl) ReqConfs() *req.Confs {
basePermCode := "msg:tmpl:base"
reqs := [...]*req.Conf{
req.NewGet("", m.GetMsgTmpls).RequiredPermissionCode(basePermCode),
req.NewGet(":id/channels", m.GetMsgTmplChannels).RequiredPermissionCode(basePermCode),
req.NewPost("", m.SaveMsgTmpl).Log(req.NewLogSaveI(imsg.LogMsgTmplSave)).RequiredPermissionCode("msg:tmpl:save"),
req.NewDelete("", m.DelMsgTmpls).Log(req.NewLogSaveI(imsg.LogMsgTmplDelete)).RequiredPermissionCode("msg:tmpl:del"),
req.NewPost(":code/send", m.SendMsg).Log(req.NewLogSaveI(imsg.LogMsgTmplSave)).RequiredPermissionCode("msg:tmpl:send"),
}
return req.NewConfs("/msg/tmpls", reqs[:]...)
}
func (m *MsgTmpl) GetMsgTmpls(rc *req.Ctx) {
condition := &entity.MsgTmpl{
Code: rc.Query("code"),
}
condition.Id = cast.ToUint64(rc.QueryInt("id"))
res, err := m.msgTmplApp.GetPageList(condition, rc.GetPageParam(), new([]entity.MsgTmpl))
biz.ErrIsNil(err)
rc.ResData = res
}
func (m *MsgTmpl) GetMsgTmplChannels(rc *req.Ctx) {
channels, err := m.msgTmplApp.GetTmplChannels(rc.MetaCtx, cast.ToUint64(rc.PathParamInt("id")))
biz.ErrIsNil(err)
rc.ResData = collx.ArrayMap(channels, func(val *entity.MsgChannel) collx.M {
return collx.M{
"id": val.Id,
"name": val.Name,
"type": val.Type,
"code": val.Code,
}
})
}
func (m *MsgTmpl) SaveMsgTmpl(rc *req.Ctx) {
form := &form.MsgTmpl{}
rc.ReqParam = form
channel := req.BindJsonAndCopyTo(rc, form, new(dto.MsgTmplSave))
biz.ErrIsNil(m.msgTmplApp.SaveTmpl(rc.MetaCtx, channel))
}
func (m *MsgTmpl) DelMsgTmpls(rc *req.Ctx) {
idsStr := rc.Query("id")
rc.ReqParam = idsStr
ids := strings.Split(idsStr, ",")
for _, v := range ids {
biz.ErrIsNil(m.msgTmplApp.DeleteTmpl(rc.MetaCtx, cast.ToUint64(v)))
}
}
func (m *MsgTmpl) SendMsg(rc *req.Ctx) {
code := rc.PathParam("code")
form := &form.SendMsg{}
req.BindJsonAndValid(rc, form)
rc.ReqParam = form
params, err := jsonx.ToMap(form.Parmas)
biz.ErrIsNil(err)
biz.ErrIsNil(m.msgTmplApp.Send(rc.MetaCtx, code, params, form.ReceiverIds...))
}

View File

@@ -1,11 +1,15 @@
package application
import (
_ "mayfly-go/internal/msg/msgx/sender" // 注册消息发送器
"mayfly-go/pkg/ioc"
)
func InitIoc() {
ioc.Register(new(msgAppImpl), ioc.WithComponentName("MsgApp"))
ioc.Register(new(msgChannelAppImpl), ioc.WithComponentName("MsgChannelApp"))
ioc.Register(new(msgTmplAppImpl), ioc.WithComponentName("MsgTmplApp"))
ioc.Register(new(msgTmplBizAppImpl), ioc.WithComponentName("MsgTmplBizApp"))
}
func GetMsgApp() Msg {

View File

@@ -0,0 +1,36 @@
package dto
import (
"mayfly-go/internal/msg/domain/entity"
"mayfly-go/internal/msg/msgx"
"mayfly-go/pkg/model"
)
type MsgTmplSave struct {
model.ExtraData
Id uint64 `json:"id"`
Name string `json:"name"`
Remark string `json:"remark"`
Status entity.MsgTmplStatus `json:"status" `
Title string `json:"title"`
Tmpl string `json:"type"`
MsgType msgx.MsgType `json:"msgType"`
ChannelIds []uint64 `json:"channelIds"`
}
// MsgTmplBizSave 消息模板关联业务信息
type MsgTmplBizSave struct {
TmplId uint64 // 消息模板id
BizId uint64 // 业务id
BizType string
}
// BizMsgTmplSend 业务消息模板发送消息
type BizMsgTmplSend struct {
BizId uint64 // 业务id
BizType string
Params map[string]any // 模板占位符参数
ReceiverIds []uint64 // 接收人id
}

View File

@@ -0,0 +1,52 @@
package application
import (
"context"
"mayfly-go/internal/msg/domain/entity"
"mayfly-go/internal/msg/domain/repository"
"mayfly-go/pkg/base"
"mayfly-go/pkg/model"
"mayfly-go/pkg/utils/stringx"
)
type MsgChannel interface {
base.App[*entity.MsgChannel]
GetPageList(condition *entity.MsgChannel, pageParam *model.PageParam, toEntity any, orderBy ...string) (*model.PageResult[any], error)
SaveChannel(ctx context.Context, msgChannel *entity.MsgChannel) error
DeleteChannel(ctx context.Context, id uint64) error
}
type msgChannelAppImpl struct {
base.AppImpl[*entity.MsgChannel, repository.MsgChannel]
msgTempApp MsgTmpl `inject:"T"`
}
var _ (MsgChannel) = (*msgChannelAppImpl)(nil)
func (m *msgChannelAppImpl) GetPageList(condition *entity.MsgChannel, pageParam *model.PageParam, toEntity any, orderBy ...string) (*model.PageResult[any], error) {
return m.Repo.GetPageList(condition, pageParam, toEntity)
}
func (m *msgChannelAppImpl) SaveChannel(ctx context.Context, msgChannel *entity.MsgChannel) error {
if msgChannel.Id == 0 {
msgChannel.Code = stringx.Rand(8)
}
return m.Save(ctx, msgChannel)
}
func (m *msgChannelAppImpl) DeleteChannel(ctx context.Context, id uint64) error {
return m.Tx(ctx, func(ctx context.Context) error {
if err := m.DeleteById(ctx, id); err != nil {
return err
}
// 删除渠道关联的模板
if err := m.msgTempApp.DeleteTmplChannel(ctx, id); err != nil {
return err
}
return nil
})
}

View File

@@ -0,0 +1,208 @@
package application
import (
"context"
"mayfly-go/internal/msg/application/dto"
"mayfly-go/internal/msg/domain/entity"
"mayfly-go/internal/msg/domain/repository"
"mayfly-go/internal/msg/msgx"
sysapp "mayfly-go/internal/sys/application"
sysentity "mayfly-go/internal/sys/domain/entity"
"mayfly-go/pkg/base"
"mayfly-go/pkg/errorx"
"mayfly-go/pkg/logx"
"mayfly-go/pkg/model"
"mayfly-go/pkg/utils/collx"
"mayfly-go/pkg/utils/stringx"
"mayfly-go/pkg/utils/structx"
)
type MsgTmpl interface {
base.App[*entity.MsgTmpl]
GetPageList(condition *entity.MsgTmpl, pageParam *model.PageParam, toEntity any, orderBy ...string) (*model.PageResult[any], error)
SaveTmpl(ctx context.Context, msgTmpl *dto.MsgTmplSave) error
DeleteTmpl(ctx context.Context, id uint64) error
GetTmplChannels(ctx context.Context, tmplId uint64) ([]*entity.MsgChannel, error)
// Send 发送消息
Send(ctx context.Context, tmplCode string, params map[string]any, receiverId ...uint64) error
// DeleteTmplChannel 删除指定渠道关联的模板
DeleteTmplChannel(ctx context.Context, channelId uint64) error
}
type msgTmplAppImpl struct {
base.AppImpl[*entity.MsgTmpl, repository.MsgTmpl]
msgTmplChannelRepo repository.MsgTmplChannel `inject:"T"`
msgChannelApp MsgChannel `inject:"T"`
msgTmplBizApp MsgTmplBiz `inject:"T"`
accountApp sysapp.Account `inject:"T"`
}
var _ (MsgTmpl) = (*msgTmplAppImpl)(nil)
func (m *msgTmplAppImpl) GetPageList(condition *entity.MsgTmpl, pageParam *model.PageParam, toEntity any, orderBy ...string) (*model.PageResult[any], error) {
return m.Repo.GetPageList(condition, pageParam, toEntity)
}
func (m *msgTmplAppImpl) SaveTmpl(ctx context.Context, msgTmpl *dto.MsgTmplSave) error {
return m.Tx(ctx, func(ctx context.Context) error {
mt := &entity.MsgTmpl{}
structx.Copy(mt, msgTmpl)
isCreate := mt.Id == 0
if isCreate {
mt.Code = stringx.Rand(8)
}
if err := m.Save(ctx, mt); err != nil {
return err
}
oldTemplChannelIds := []uint64{}
if !isCreate {
oldTemplChannels, err := m.msgTmplChannelRepo.SelectByCond(&entity.MsgTmplChannel{TmplId: mt.Id}, "channel_id")
if err != nil {
return err
}
oldTemplChannelIds = collx.ArrayMap(oldTemplChannels, func(c *entity.MsgTmplChannel) uint64 {
return c.ChannelId
})
}
add, del, _ := collx.ArrayCompare(msgTmpl.ChannelIds, oldTemplChannelIds)
if len(add) > 0 {
tmplChannels := collx.ArrayMap(msgTmpl.ChannelIds, func(channelId uint64) *entity.MsgTmplChannel {
return &entity.MsgTmplChannel{
ChannelId: channelId,
TmplId: mt.Id,
}
})
if err := m.msgTmplChannelRepo.BatchInsert(ctx, tmplChannels); err != nil {
return err
}
}
if len(del) > 0 {
if err := m.msgTmplChannelRepo.DeleteByCond(ctx, model.NewCond().Eq("tmpl_id", mt.Id).In("channel_id", del)); err != nil {
return err
}
}
return nil
})
}
func (m *msgTmplAppImpl) DeleteTmpl(ctx context.Context, id uint64) error {
return m.Tx(ctx, func(ctx context.Context) error {
if err := m.DeleteById(ctx, id); err != nil {
return err
}
if err := m.msgTmplBizApp.DeleteByTmplId(ctx, id); err != nil {
return err
}
return m.msgTmplChannelRepo.DeleteByCond(ctx, &entity.MsgTmplChannel{TmplId: id})
})
}
func (m *msgTmplAppImpl) GetTmplChannels(ctx context.Context, tmplId uint64) ([]*entity.MsgChannel, error) {
tmplChannels, err := m.msgTmplChannelRepo.SelectByCond(&entity.MsgTmplChannel{TmplId: tmplId}, "channel_id")
if err != nil {
return nil, err
}
if len(tmplChannels) == 0 {
return []*entity.MsgChannel{}, nil
}
return m.msgChannelApp.GetByIds(collx.ArrayMap(tmplChannels, func(c *entity.MsgTmplChannel) uint64 {
return c.ChannelId
}))
}
func (m *msgTmplAppImpl) Send(ctx context.Context, tmplCode string, params map[string]any, receiverId ...uint64) error {
tmpl := &entity.MsgTmpl{Code: tmplCode}
err := m.GetByCond(tmpl)
if err != nil {
return errorx.NewBiz("message template does not exist")
}
if tmpl.Status != entity.TmplStatusEnable {
return errorx.NewBiz("message template is disabled")
}
tmplChannels, err := m.msgTmplChannelRepo.SelectByCond(&entity.MsgTmplChannel{TmplId: tmpl.Id}, "channel_id")
if err != nil {
return err
}
if len(tmplChannels) == 0 {
return errorx.NewBiz("message template is not associated with any channel")
}
channels, err := m.msgChannelApp.GetByIds(collx.ArrayMap(tmplChannels, func(c *entity.MsgTmplChannel) uint64 {
return c.ChannelId
}))
if err != nil {
return err
}
// content, err := stringx.TemplateParse(tmpl.Tmpl, params)
// if err != nil {
// return err
// }
// toAll := len(receiverId) == 0
accounts, err := m.accountApp.GetByIds(receiverId)
if err != nil {
return err
}
msg := &msgx.Msg{
Content: tmpl.Tmpl,
Params: params,
Title: tmpl.Title,
Type: tmpl.MsgType,
ExtraData: tmpl.ExtraData,
}
if len(accounts) > 0 {
msg.Receivers = collx.ArrayMap(accounts, func(account *sysentity.Account) msgx.Receiver {
return msgx.Receiver{
ExtraData: account.ExtraData,
Email: account.Email,
Mobile: account.Mobile,
}
})
}
for _, channel := range channels {
if channel.Status != entity.ChannelStatusEnable {
logx.Warnf("channel is disabled => %s", channel.Code)
continue
}
go func(channel *entity.MsgChannel) {
if err := msgx.Send(&msgx.Channel{
Type: channel.Type,
Name: channel.Name,
URL: channel.Url,
ExtraData: channel.ExtraData,
}, msg); err != nil {
logx.Errorf("send msg error => channel=%s, msg=%s, err -> %v", channel.Code, msg.Content, err)
}
}(channel)
}
return nil
}
func (m *msgTmplAppImpl) DeleteTmplChannel(ctx context.Context, channelId uint64) error {
return m.msgTmplChannelRepo.DeleteByCond(ctx, &entity.MsgTmplChannel{ChannelId: channelId})
}

View File

@@ -0,0 +1,102 @@
package application
import (
"context"
"mayfly-go/internal/msg/application/dto"
"mayfly-go/internal/msg/domain/entity"
"mayfly-go/internal/msg/domain/repository"
"mayfly-go/pkg/base"
"mayfly-go/pkg/errorx"
)
type MsgTmplBiz interface {
base.App[*entity.MsgTmplBiz]
// SaveBizTmpl 保存消息模板关联业务信息
SaveBizTmpl(ctx context.Context, bizTmpl dto.MsgTmplBizSave) error
// DeleteByBiz 根据业务删除消息模板业务关联
DeleteByBiz(ctx context.Context, bizType string, bizId uint64) error
// DeleteByTmplId 根据模板ID删除消息模板业务关联
DeleteByTmplId(ctx context.Context, tmplId uint64) error
// Send 发送消息
Send(ctx context.Context, sendParam dto.BizMsgTmplSend) error
}
type msgTmplBizAppImpl struct {
base.AppImpl[*entity.MsgTmplBiz, repository.MsgTmplBiz]
msgTmplApp MsgTmpl `inject:"T"`
}
var _ (MsgTmplBiz) = (*msgTmplBizAppImpl)(nil)
func (m *msgTmplBizAppImpl) SaveBizTmpl(ctx context.Context, bizTmpl dto.MsgTmplBizSave) error {
msgTmplId := bizTmpl.TmplId
bizId := bizTmpl.BizId
bizType := bizTmpl.BizType
if bizId == 0 {
return errorx.NewBiz("business ID cannot be empty")
}
if bizType == "" {
return errorx.NewBiz("business type cannot be empty")
}
msgTmplBiz := &entity.MsgTmplBiz{
BizId: bizId,
BizType: bizType,
}
// exist
if err := m.GetByCond(msgTmplBiz); err == nil {
// tmplId不变直接返回即可
if msgTmplBiz.TmplId == msgTmplId {
return nil
}
// 如果模板ID为0表示删除业务关联
if msgTmplId == 0 {
return m.DeleteByBiz(ctx, bizTmpl.BizType, bizTmpl.BizId)
}
update := &entity.MsgTmplBiz{
TmplId: msgTmplId,
}
update.Id = msgTmplBiz.Id
return m.UpdateById(ctx, update)
}
if msgTmplId == 0 {
return nil
}
msgTmplBiz.TmplId = msgTmplId
return m.Save(ctx, msgTmplBiz)
}
func (m *msgTmplBizAppImpl) DeleteByBiz(ctx context.Context, bizType string, bizId uint64) error {
return m.DeleteByCond(ctx, &entity.MsgTmplBiz{BizId: bizId, BizType: bizType})
}
func (m *msgTmplBizAppImpl) DeleteByTmplId(ctx context.Context, tmplId uint64) error {
return m.DeleteByCond(ctx, &entity.MsgTmplBiz{TmplId: tmplId})
}
func (m *msgTmplBizAppImpl) Send(ctx context.Context, sendParam dto.BizMsgTmplSend) error {
// 获取业务关联的消息模板
msgTmplBiz := &entity.MsgTmplBiz{
BizId: sendParam.BizId,
BizType: sendParam.BizType,
}
if err := m.GetByCond(msgTmplBiz); err != nil {
return errorx.NewBiz("message tmplate association business information does not exist")
}
mstTmpl, err := m.msgTmplApp.GetById(msgTmplBiz.TmplId)
if err != nil {
return errorx.NewBiz("message template does not exist")
}
return m.msgTmplApp.Send(ctx, mstTmpl.Code, sendParam.Params, sendParam.ReceiverIds...)
}

View File

@@ -0,0 +1,29 @@
package entity
import (
"mayfly-go/internal/msg/msgx"
"mayfly-go/pkg/model"
)
type MsgChannel struct {
model.Model
model.ExtraData
Name string `json:"name" gorm:"size:50;not null;"` // 渠道名称
Code string `json:"code" gorm:"size:50;not null;"` // 渠道编码
Type msgx.ChannelType `json:"type" gorm:"size:30;not null;"` // 渠道类型
Url string `json:"url" gorm:"size:200;"` // 渠道url
Status MsgChannelStatus `json:"status" gorm:"not null;"` // 状态
Remark *string `json:"remark" gorm:"size:200;"` // 备注
}
func (a *MsgChannel) TableName() string {
return "t_msg_channel"
}
type MsgChannelStatus int8
const (
ChannelStatusEnable MsgChannelStatus = 1 // 启用状态
ChannelStatusDisable MsgChannelStatus = -1 // 禁用状态
)

View File

@@ -0,0 +1,42 @@
package entity
import (
"mayfly-go/internal/msg/msgx"
"mayfly-go/pkg/model"
)
// MsgTmpl 消息模板
type MsgTmpl struct {
model.Model
model.ExtraData
Name string `json:"name" gorm:"size:50;not null;"` // 模板名称
Code string `json:"code" gorm:"size:32;not null;"` // 模板编码
Title string `json:"title" gorm:"size:100;"` // 标题
Tmpl string `json:"tmpl" gorm:"size:2000;not null;"` // 消息模板
MsgType msgx.MsgType `json:"msgType" gorm:"not null;"` // 消息类型
Status MsgTmplStatus `json:"status" gorm:"not null;"` // 状态
Remark *string `json:"remark" gorm:"size:200;"` // 备注
}
func (a *MsgTmpl) TableName() string {
return "t_msg_tmpl"
}
type MsgTmplStatus int8
const (
TmplStatusEnable MsgTmplStatus = 1 // 启用状态
TmplStatusDisable MsgTmplStatus = -1 // 禁用状态
)
// MsgTmplChannel 消息模板渠道关联
type MsgTmplChannel struct {
model.CreateModelNLD
TmplId uint64 `json:"tmplId" gorm:"not null;"` // 模板id
ChannelId uint64 `json:"channelId" gorm:"not null;"` // 渠道id
}
func (a *MsgTmplChannel) TableName() string {
return "t_msg_tmpl_channel"
}

View File

@@ -0,0 +1,18 @@
package entity
import (
"mayfly-go/pkg/model"
)
// MsgTmplBiz 消息模板关联业务信息
type MsgTmplBiz struct {
model.Model
TmplId uint64 `json:"tmplId" gorm:"not null;"` // 模板id
BizId uint64 `json:"bizId" gorm:"not null;"` // 业务id
BizType string `json:"bizType" gorm:"size:32;not null;"` // 业务类型
}
func (a *MsgTmplBiz) TableName() string {
return "t_msg_tmpl_biz"
}

View File

@@ -0,0 +1,13 @@
package repository
import (
"mayfly-go/internal/msg/domain/entity"
"mayfly-go/pkg/base"
"mayfly-go/pkg/model"
)
type MsgChannel interface {
base.Repo[*entity.MsgChannel]
GetPageList(condition *entity.MsgChannel, pageParam *model.PageParam, toEntity any, orderBy ...string) (*model.PageResult[any], error)
}

View File

@@ -0,0 +1,17 @@
package repository
import (
"mayfly-go/internal/msg/domain/entity"
"mayfly-go/pkg/base"
"mayfly-go/pkg/model"
)
type MsgTmpl interface {
base.Repo[*entity.MsgTmpl]
GetPageList(condition *entity.MsgTmpl, pageParam *model.PageParam, toEntity any, orderBy ...string) (*model.PageResult[any], error)
}
type MsgTmplChannel interface {
base.Repo[*entity.MsgTmplChannel]
}

View File

@@ -0,0 +1,10 @@
package repository
import (
"mayfly-go/internal/msg/domain/entity"
"mayfly-go/pkg/base"
)
type MsgTmplBiz interface {
base.Repo[*entity.MsgTmplBiz]
}

View File

@@ -0,0 +1,12 @@
package imsg
import "mayfly-go/pkg/i18n"
var En = map[i18n.MsgId]string{
LogMsgChannelSave: "Message channel- save",
LogMsgChannelDelete: "Message channel- delete",
LogMsgTmplSave: "Message template- save",
LogMsgTmplDelete: "Message template- delete",
LogMsgTmplSend: "Message template- send",
}

View File

@@ -0,0 +1,20 @@
package imsg
import (
"mayfly-go/internal/common/consts"
"mayfly-go/pkg/i18n"
)
func init() {
i18n.AppendLangMsg(i18n.Zh_CN, Zh_CN)
i18n.AppendLangMsg(i18n.En, En)
}
const (
LogMsgChannelSave = iota + consts.ImsgNumMsg
LogMsgChannelDelete
LogMsgTmplSave
LogMsgTmplDelete
LogMsgTmplSend
)

View File

@@ -0,0 +1,12 @@
package imsg
import "mayfly-go/pkg/i18n"
var Zh_CN = map[i18n.MsgId]string{
LogMsgChannelSave: "消息渠道-保存",
LogMsgChannelDelete: "消息渠道-删除",
LogMsgTmplSave: "消息模板-保存",
LogMsgTmplDelete: "消息模板-删除",
LogMsgTmplSend: "消息模板-发送",
}

View File

@@ -0,0 +1,24 @@
package persistence
import (
"mayfly-go/internal/msg/domain/entity"
"mayfly-go/internal/msg/domain/repository"
"mayfly-go/pkg/base"
"mayfly-go/pkg/model"
)
type msgChannelRepoImpl struct {
base.RepoImpl[*entity.MsgChannel]
}
func newMsgChannelRepo() repository.MsgChannel {
return &msgChannelRepoImpl{}
}
func (m *msgChannelRepoImpl) GetPageList(condition *entity.MsgChannel, pageParam *model.PageParam, toEntity any, orderBy ...string) (*model.PageResult[any], error) {
pd := model.NewCond().
Eq("id", condition.Id).
Like("code", condition.Code).
OrderBy(orderBy...)
return m.PageByCondToAny(pd, pageParam, toEntity)
}

View File

@@ -0,0 +1,32 @@
package persistence
import (
"mayfly-go/internal/msg/domain/entity"
"mayfly-go/internal/msg/domain/repository"
"mayfly-go/pkg/base"
"mayfly-go/pkg/model"
)
type msgTmplRepoImpl struct {
base.RepoImpl[*entity.MsgTmpl]
}
func newMsgTmplRepo() repository.MsgTmpl {
return &msgTmplRepoImpl{}
}
func (m *msgTmplRepoImpl) GetPageList(condition *entity.MsgTmpl, pageParam *model.PageParam, toEntity any, orderBy ...string) (*model.PageResult[any], error) {
pd := model.NewCond().
Eq("id", condition.Id).
Like("code", condition.Code).
OrderBy(orderBy...)
return m.PageByCondToAny(pd, pageParam, toEntity)
}
type msgTmplChannelRepoImpl struct {
base.RepoImpl[*entity.MsgTmplChannel]
}
func newMsgTmplChannelRepo() repository.MsgTmplChannel {
return &msgTmplChannelRepoImpl{}
}

View File

@@ -0,0 +1,15 @@
package persistence
import (
"mayfly-go/internal/msg/domain/entity"
"mayfly-go/internal/msg/domain/repository"
"mayfly-go/pkg/base"
)
type msgTmplBizRepoImpl struct {
base.RepoImpl[*entity.MsgTmplBiz]
}
func newMsgTmplBizRepo() repository.MsgTmplBiz {
return &msgTmplBizRepoImpl{}
}

View File

@@ -6,4 +6,8 @@ import (
func InitIoc() {
ioc.Register(newMsgRepo(), ioc.WithComponentName("MsgRepo"))
ioc.Register(newMsgChannelRepo(), ioc.WithComponentName("MsgChannelRepo"))
ioc.Register(newMsgTmplRepo(), ioc.WithComponentName("MsgTmplRepo"))
ioc.Register(newMsgTmplChannelRepo(), ioc.WithComponentName("MsgTmplChannelRepo"))
ioc.Register(newMsgTmplBizRepo(), ioc.WithComponentName("MsgTmplBizRepo"))
}

View File

@@ -1,10 +1,16 @@
package init
import (
"context"
"mayfly-go/initialize"
"mayfly-go/internal/event"
"mayfly-go/internal/msg/api"
"mayfly-go/internal/msg/application"
"mayfly-go/internal/msg/application/dto"
"mayfly-go/internal/msg/infrastructure/persistence"
"mayfly-go/pkg/eventbus"
"mayfly-go/pkg/global"
"mayfly-go/pkg/ioc"
)
func init() {
@@ -13,4 +19,14 @@ func init() {
application.InitIoc()
api.InitIoc()
})
initialize.AddInitFunc(Init)
}
func Init() {
msgTmplBizApp := ioc.Get[application.MsgTmplBiz]("MsgTmplBizApp")
global.EventBus.SubscribeAsync(event.EventTopicBizMsgTmplSend, "BizMsgTmplSend", func(ctx context.Context, event *eventbus.Event) error {
return msgTmplBizApp.Send(ctx, event.Val.(dto.BizMsgTmplSend))
}, false)
}

View File

@@ -0,0 +1,84 @@
package msgx
import (
"fmt"
"mayfly-go/pkg/model"
)
type MsgType int8
type ChannelType string
const (
MsgTypeText MsgType = 1
MsgTypeMarkdown MsgType = 2
MsgTypeHtml MsgType = 3
)
const (
ChannelTypeEmail ChannelType = "email"
ChannelTypeDingBot ChannelType = "dingBot"
ChannelTypeQywxBot ChannelType = "qywxBot"
ChannelTypeFeishuBot ChannelType = "feishuBot"
)
const (
ReceiverKey = "receiver"
)
// Send 发送消息
func Send(channel *Channel, msg *Msg) error {
sender, err := GetMsgSender(channel.Type)
if err != nil {
return err
}
return sender.Send(channel, msg)
}
type Receiver struct {
model.ExtraData
Mobile string
Email string
}
type Msg struct {
model.ExtraData
Title string // 消息title
Type MsgType // 消息类型
Content string // 消息内容
Params map[string]any // 消息参数(替换消息中的占位符)
Receivers []Receiver // 消息接收人
}
// Channel 消息发送渠道信息
type Channel struct {
model.ExtraData
Type ChannelType // 渠道类型
Name string
URL string
}
// MsgSender 定义消息发送接口
type MsgSender interface {
// Send 发送消息
Send(channel *Channel, msg *Msg) error
}
var messageSenders = make(map[ChannelType]MsgSender)
// RegisterMsgSender 注册消息发送器
func RegisterMsgSender(channel ChannelType, sender MsgSender) {
messageSenders[channel] = sender
}
// GetMsgSender 获取消息发送器
func GetMsgSender(channel ChannelType) (MsgSender, error) {
sender, ok := messageSenders[channel]
if !ok {
return nil, fmt.Errorf("unsupported message channel %s", channel)
}
return sender, nil
}

View File

@@ -0,0 +1,107 @@
package sender
import (
"crypto/hmac"
"crypto/sha256"
"encoding/base64"
"errors"
"fmt"
"mayfly-go/internal/msg/msgx"
"mayfly-go/pkg/httpx"
"mayfly-go/pkg/utils/collx"
"mayfly-go/pkg/utils/stringx"
"strings"
"net/url"
"time"
)
type dingBotMsgReq struct {
MsgType string `json:"msgtype"`
Text struct {
Content string `json:"content"`
} `json:"text"`
Markdown struct {
Title string `json:"title"`
Text string `json:"text"`
} `json:"markdown"`
At struct {
// AtUserIds []string `json:"atUserIds"`
AtMobiles []string `json:"atMobiles"`
IsAtAll bool `json:"isAtAll"`
} `json:"at"`
}
type dingBotMsgResp struct {
Code int `json:"errcode"`
Message string `json:"errmsg"`
}
// DingBotSender 钉钉机器人消息发送
type DingBotSender struct{}
func (d DingBotSender) Send(channel *msgx.Channel, msg *msgx.Msg) error {
// https://open.dingtalk.com/document/robots/custom-robot-access#title-72m-8ag-pqw
msgReq := dingBotMsgReq{}
params := msg.Params
receiver := collx.ArrayMapFilter(msg.Receivers, func(a msgx.Receiver) (string, bool) {
return a.Mobile, a.Mobile != ""
})
if len(receiver) > 0 {
msgReq.At.AtMobiles = receiver
// 替换文本中的receiver使用@mobile用于@指定用户
params[msgx.ReceiverKey] = strings.Join(collx.ArrayMap(receiver, func(a string) string { return "@" + a }), "")
} else {
msgReq.At.IsAtAll = true
params[msgx.ReceiverKey] = ""
}
content, err := stringx.TemplateResolve(msg.Content, params)
if err != nil {
return err
}
if msg.Type == msgx.MsgTypeMarkdown {
msgReq.MsgType = "markdown"
msgReq.Markdown.Title = msg.Title
msgReq.Markdown.Text = content
} else {
msgReq.MsgType = "text"
msgReq.Text.Content = content
}
timestamp := time.Now().UnixMilli()
sign, err := d.sign(channel.GetExtraString("secret"), timestamp)
if err != nil {
return err
}
var res dingBotMsgResp
err = httpx.NewReq(fmt.Sprintf("%s&timestamp=%d&sign=%s", channel.URL, timestamp, sign)).
PostObj(msgReq).
BodyTo(&res)
if err != nil {
return err
}
if res.Code != 0 {
return errors.New(res.Message)
}
return nil
}
func (d DingBotSender) sign(secret string, timestamp int64) (string, error) {
// https://open.dingtalk.com/document/robots/customize-robot-security-settings
// timestamp + key -> sha256 -> URL encode
stringToSign := fmt.Sprintf("%d\n%s", timestamp, secret)
h := hmac.New(sha256.New, []byte(secret))
_, err := h.Write([]byte(stringToSign))
if err != nil {
return "", err
}
signature := base64.StdEncoding.EncodeToString(h.Sum(nil))
signature = url.QueryEscape(signature)
return signature, nil
}

View File

@@ -0,0 +1,100 @@
package sender
import (
"crypto/tls"
"encoding/base64"
"errors"
"fmt"
"mayfly-go/internal/msg/msgx"
"mayfly-go/pkg/utils/collx"
"mayfly-go/pkg/utils/stringx"
"net/smtp"
"strings"
"github.com/may-fly/cast"
)
type EmailSender struct{}
func (e EmailSender) Send(channel *msgx.Channel, msg *msgx.Msg) error {
return e.SendEmail(channel, msg)
}
func (e EmailSender) SendEmail(channel *msgx.Channel, msg *msgx.Msg) error {
subject := msg.Title
content, err := stringx.TemplateResolve(msg.Content, msg.Params)
if err != nil {
return err
}
to := collx.ArrayMapFilter(msg.Receivers, func(a msgx.Receiver) (string, bool) {
return a.Email, a.Email != ""
})
if len(to) == 0 {
return errors.New("no receiver")
}
systemName := "mayfly-go"
serverAndPort := strings.Split(channel.URL, ":")
smtpServer := serverAndPort[0]
smtpPort := 465
if len(serverAndPort) == 2 {
smtpPort = cast.ToInt(serverAndPort[1])
}
smtpAccount := channel.GetExtraString("smtpAccount")
smtpPassword := channel.GetExtraString("smtpPassword")
encodedSubject := fmt.Sprintf("=?UTF-8?B?%s?=", base64.StdEncoding.EncodeToString([]byte(subject)))
mail := []byte(fmt.Sprintf("To: %s\r\n"+
"From: %s<%s>\r\n"+
"Subject: %s\r\n"+
"Content-Type: text/html; charset=UTF-8\r\n\r\n%s\r\n",
strings.Join(to, ";"), systemName, smtpAccount, encodedSubject, content))
auth := smtp.PlainAuth("", smtpAccount, smtpPassword, smtpServer)
addr := fmt.Sprintf("%s:%d", smtpServer, smtpPort)
if smtpPort == 465 {
tlsConfig := &tls.Config{
InsecureSkipVerify: true,
ServerName: smtpServer,
}
conn, err := tls.Dial("tcp", fmt.Sprintf("%s:%d", smtpServer, smtpPort), tlsConfig)
if err != nil {
return err
}
client, err := smtp.NewClient(conn, smtpServer)
if err != nil {
return err
}
defer client.Close()
if err = client.Auth(auth); err != nil {
return err
}
if err = client.Mail(smtpAccount); err != nil {
return err
}
for _, receiver := range to {
if err = client.Rcpt(receiver); err != nil {
return err
}
}
w, err := client.Data()
if err != nil {
return err
}
_, err = w.Write(mail)
if err != nil {
return err
}
err = w.Close()
if err != nil {
return err
}
} else {
err = smtp.SendMail(addr, auth, smtpAccount, to, mail)
}
return err
}

View File

@@ -0,0 +1,100 @@
package sender
import (
"crypto/hmac"
"crypto/sha256"
"encoding/base64"
"errors"
"fmt"
"mayfly-go/internal/msg/msgx"
"mayfly-go/pkg/httpx"
"mayfly-go/pkg/utils/collx"
"mayfly-go/pkg/utils/stringx"
"strings"
"time"
"github.com/may-fly/cast"
)
type feishuBotMsgReq struct {
MsgType string `json:"msg_type"`
Content struct {
Text string `json:"text"`
} `json:"content"`
Timestamp string `json:"timestamp"`
Sign string `json:"sign"`
}
type feishuBotMsgResp struct {
Code int `json:"code"`
Message string `json:"msg"`
}
// FeishuBotSender 发送飞书机器人消息
type FeishuBotSender struct{}
func (f FeishuBotSender) Send(channel *msgx.Channel, msg *msgx.Msg) error {
// https://open.feishu.cn/document/client-docs/bot-v3/add-custom-bot
msgReq := feishuBotMsgReq{
MsgType: "text",
}
params := msg.Params
receiver := `<at user_id="all"></at>`
// 使用receiver参数替换消息内容中可能存在的接收人信息
if len(msg.Receivers) > 0 {
if to := collx.ArrayMapFilter(msg.Receivers, func(a msgx.Receiver) (string, bool) {
if uid := a.GetExtraString("feishuUserId"); uid != "" {
// 使用<at user_id="userId"></at>
return fmt.Sprintf(`<at user_id="%s"></at>`, uid), true
}
return "", false
}); len(to) > 0 {
receiver = strings.Join(to, "")
}
}
params[msgx.ReceiverKey] = receiver
content, err := stringx.TemplateResolve(msg.Content, params)
if err != nil {
return err
}
msgReq.Content.Text = content
if secret := channel.GetExtraString("secret"); secret != "" {
timestamp := time.Now().Unix()
if sign, err := f.sign(secret, timestamp); err != nil {
return err
} else {
msgReq.Sign = sign
}
msgReq.Timestamp = cast.ToString(timestamp)
}
var res feishuBotMsgResp
err = httpx.NewReq(channel.URL).
PostObj(msgReq).
BodyTo(&res)
if err != nil {
return err
}
if res.Code != 0 {
return errors.New(res.Message)
}
return nil
}
func (e FeishuBotSender) sign(secret string, timestamp int64) (string, error) {
//timestamp + key 做sha256, 再进行base64 encode
stringToSign := fmt.Sprintf("%v", timestamp) + "\n" + secret
var data []byte
h := hmac.New(sha256.New, []byte(stringToSign))
_, err := h.Write(data)
if err != nil {
return "", err
}
signature := base64.StdEncoding.EncodeToString(h.Sum(nil))
return signature, nil
}

View File

@@ -0,0 +1,82 @@
package sender
import (
"errors"
"fmt"
"mayfly-go/internal/msg/msgx"
"mayfly-go/pkg/httpx"
"mayfly-go/pkg/utils/collx"
"mayfly-go/pkg/utils/stringx"
"strings"
)
type qywxBotMsgReq struct {
MsgType string `json:"msgtype"`
Text struct {
Content string `json:"content"`
MentionedMobileList []string `json:"mentioned_mobile_list"`
} `json:"text"`
Markdown struct {
Content string `json:"content"`
} `json:"markdown"`
}
type qywxBotMsgResp struct {
Code int `json:"errcode"`
Message string `json:"errmsg"`
}
// QywxBotSender 企业微信机器人消息发送
type QywxBotSender struct{}
func (e QywxBotSender) Send(channel *msgx.Channel, msg *msgx.Msg) error {
// https://developer.work.weixin.qq.com/document/path/91770
msgReq := qywxBotMsgReq{}
params := msg.Params
receiver := ""
// 使用receiver参数替换消息内容中可能存在的接收人信息
if len(msg.Receivers) > 0 {
if to := collx.ArrayMapFilter(msg.Receivers, func(a msgx.Receiver) (string, bool) {
if uid := a.GetExtraString("qywxUserId"); uid != "" {
// 使用<@userId>用于@指定用户
return fmt.Sprintf("<@%s>", uid), true
}
return "", false
}); len(to) > 0 {
receiver = strings.Join(to, "")
}
}
params[msgx.ReceiverKey] = receiver
content, err := stringx.TemplateResolve(msg.Content, params)
if err != nil {
return err
}
if msg.Type == msgx.MsgTypeMarkdown {
msgReq.MsgType = "markdown"
msgReq.Markdown.Content = content
// msgReq.Markdown.MentionedMobileList = receivers // markdown不支持@人,需要使用<@userId>
} else {
msgReq.MsgType = "text"
msgReq.Text.Content = content
// receivers := msg.Receivers
// if len(msg.Receivers) == 0 {
// receivers = []string{"@all"}
// }
// msgReq.Text.MentionedMobileList = receivers
}
var res qywxBotMsgResp
err = httpx.NewReq(channel.URL).PostObj(msgReq).BodyTo(&res)
if err != nil {
return err
}
if res.Code != 0 {
return errors.New(res.Message)
}
return nil
}

View File

@@ -0,0 +1,10 @@
package sender
import "mayfly-go/internal/msg/msgx"
func init() {
msgx.RegisterMsgSender(msgx.ChannelTypeEmail, EmailSender{})
msgx.RegisterMsgSender(msgx.ChannelTypeDingBot, DingBotSender{})
msgx.RegisterMsgSender(msgx.ChannelTypeQywxBot, QywxBotSender{})
msgx.RegisterMsgSender(msgx.ChannelTypeFeishuBot, FeishuBotSender{})
}

View File

@@ -1,15 +1,25 @@
package form
import "mayfly-go/pkg/model"
type AccountCreateForm struct {
model.ExtraData
Id uint64 `json:"id"`
Name string `json:"name" binding:"required,max=16" msg:"required=name cannot be blank,max=The maximum length of a name cannot exceed 16 characters"`
Username string `json:"username" binding:"pattern=account_username"`
Mobile string `json:"mobile"`
Email string `json:"email" binding:"omitempty,email"`
Password string `json:"password"`
}
type AccountUpdateForm struct {
model.ExtraData
Name string `json:"name" binding:"max=16"` // 姓名
Username string `json:"username" binding:"omitempty,pattern=account_username"`
Mobile string `json:"mobile"`
Email string `json:"email" binding:"omitempty,email"`
Password *string `json:"password"`
}

View File

@@ -8,8 +8,12 @@ import (
type AccountManageVO struct {
model.Model
model.ExtraData
Name string `json:"name"`
Username string `json:"username"`
Mobile string `json:"mobile"`
Email string `json:"email"`
Status entity.AccountStatus `json:"status"`
LastLoginTime *time.Time `json:"lastLoginTime"`
OtpSecret string `json:"otpSecret"`
@@ -19,6 +23,8 @@ type SimpleAccountVO struct {
Id uint64 `json:"id"`
Name string `json:"name"`
Username string `json:"username"`
Mobile string `json:"mobile"`
Email string `json:"email"`
Roles []*AccountRoleVO `json:"roles" gorm:"-"`
}

View File

@@ -149,7 +149,7 @@ func (m *syslogAppImpl) AppendLog(logId uint64, appendLog *AppendLogReq) {
syslog.Resp = fmt.Sprintf("%s\n%s", syslog.Resp, appendLogMsg)
syslog.Type = appendLog.Type
if len(appendLog.Extra) > 0 {
existExtra := jsonx.ToMap(syslog.Extra)
existExtra, _ := jsonx.ToMap(syslog.Extra)
syslog.Extra = jsonx.ToStr(collx.MapMerge(existExtra, appendLog.Extra))
}
@@ -167,7 +167,7 @@ func (m *syslogAppImpl) SetExtra(logId uint64, key string, val any) {
syslog = sl
}
extraMap := jsonx.ToMap(syslog.Extra)
extraMap, _ := jsonx.ToMap(syslog.Extra)
if extraMap == nil {
extraMap = make(map[string]any)
}

View File

@@ -10,9 +10,12 @@ import (
type Account struct {
model.Model
model.ExtraData
Name string `json:"name" gorm:"size:30;not null;"`
Username string `json:"username" gorm:"size:30;not null;"`
Mobile string `json:"mobile" gorm:"size:20;"`
Email string `json:"email" gorm:"size:100;"`
Password string `json:"-" gorm:"size:64;not null;"`
Status AccountStatus `json:"status" gorm:"not null;"`
LastLoginTime *time.Time `json:"lastLoginTime"`

View File

@@ -22,10 +22,8 @@ func init() {
}
func Init() {
global.EventBus.SubscribeAsync(event.EventTopicResourceOp, "ResourceOpLogApp", func(ctx context.Context, event *eventbus.Event) error {
codePath := event.Val.(string)
return application.GetResourceOpLogApp().AddResourceOpLog(ctx, codePath)
}, false)
}