diff --git a/README.md b/README.md index ec607921..2ec26059 100644 --- a/README.md +++ b/README.md @@ -28,7 +28,7 @@ ## 前言 -web 版 **linux(终端[终端回放、命令过滤] 文件 脚本 进程 计划任务)。数据库(mysql postgres oracle sqlserver 达梦 高斯 sqlite)数据操作、数据同步、数据迁移。redis(单机 哨兵 集群)。mongo 等集工单流程审批于一体的统一管理操作平台。** +Web版 **统一管理操作平台**,集成了对Linux系统的全面操作支持(包括终端管理[终端回放、命令过滤]、文件管理、脚本执行、进程监控及计划任务设置),同时提供了多种数据库(如 MySQL、PostgreSQL、Oracle、SQL Server、达梦、高斯、SQLite 等)的数据操作、数据同步与数据迁移功能。此外,还支持 Redis(单机、哨兵、集群模式)以及 MongoDB 的操作管理,并结合工单流程审批功能,为企业提供一站式的运维与管理解决方案。 ## 开发语言与主要框架 @@ -107,3 +107,10 @@ http://go.mayfly.run ## 💌 支持作者 如果觉得项目不错,或者已经在使用了,希望你可以去 Github 或者 Gitee 帮我点个 ⭐ Star,这将是对我极大的鼓励与支持。 + +> 喝杯咖啡 ☕️ 或者来杯奶茶 🧋,让作者更有精神,写出更棒的代码!这将是对作者最大的鼓励和支持! + +微信打赏 + +> **特别感谢:** +> 赞助金额达 199 元以上,加微信(wx-error),我将邀请您进入付费交流群,享受更快、更优先的技术支持! diff --git a/README_EN.md b/README_EN.md index 2597267c..fd4016b0 100644 --- a/README_EN.md +++ b/README_EN.md @@ -28,7 +28,7 @@ ## Preface -Browser-based management platform. **linux(Terminal [terminal playback, command filtering], file, script, process, cronjob), database (mysql, postgres, oracle, sqlserver, Dameng, gauss, sqlite) data operation, data synchronization, data migration, redis(standlone, sentinel, cluster), mongo and other unified management and operation platforms that integrate work order process approval.** +Web-based **Unified Management and Operation Platform**, integrating comprehensive operation support for Linux systems (including terminal management [terminal playback, command filtering], file management, script execution, process monitoring, and cronjob settings). It also provides data operation, data synchronization, and data migration for multiple databases (such as MySQL, PostgreSQL, Oracle, SQL Server, Dameng, Gauss, SQLite, etc.). Additionally, it supports Redis operations (standalone, sentinel, and cluster modes) and MongoDB management, combined with work order process approval functionality to offer enterprises an all-in-one solution for operations and management. ## Development languages and major frameworks diff --git a/docker-compose.yaml b/docker-compose.yaml index 8e9718e2..74d56da2 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -14,18 +14,18 @@ services: restart: always server: - image: ccr.ccs.tencentyun.com/mayfly/mayfly-go:v1.8.5 + image: ccr.ccs.tencentyun.com/mayfly/mayfly-go:latest build: context: . dockerfile: Dockerfile container_name: mayfly-go-server ports: - - "8888:8888" + - "18888:18888" environment: TZ: Asia/Shanghai WAIT_HOSTS: mysql:3306 volumes: - - ./server/config.yml.example:/mayfly/config.yml + - ./server/config.yml:/mayfly/config.yml depends_on: - mysql restart: always diff --git a/frontend/package.json b/frontend/package.json index 953aa527..91571362 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -11,7 +11,7 @@ }, "dependencies": { "@element-plus/icons-vue": "^2.3.1", - "@vueuse/core": "^12.8.2", + "@vueuse/core": "^13.1.0", "@xterm/addon-fit": "^0.10.0", "@xterm/addon-search": "^0.15.0", "@xterm/addon-web-links": "^0.11.0", @@ -22,48 +22,46 @@ "crypto-js": "^4.2.0", "dayjs": "^1.11.13", "echarts": "^5.6.0", - "element-plus": "^2.9.5", + "element-plus": "^2.9.7", "js-base64": "^3.7.7", "jsencrypt": "^3.3.2", - "lodash": "^4.17.21", "mitt": "^3.0.1", "monaco-editor": "^0.52.2", "monaco-sql-languages": "^0.12.2", "monaco-themes": "^0.4.4", "nprogress": "^0.2.0", - "pinia": "^3.0.1", + "pinia": "^3.0.2", "qrcode.vue": "^3.6.0", "screenfull": "^6.0.2", "sortablejs": "^1.15.6", - "splitpanes": "^3.1.8", + "splitpanes": "^4.0.3", "sql-formatter": "^15.4.10", "trzsz": "^1.1.5", "uuid": "^9.0.1", "vue": "^3.5.13", - "vue-i18n": "^11.1.1", + "vue-i18n": "^11.1.3", "vue-router": "^4.5.0", "vuedraggable": "^4.1.0" }, "devDependencies": { "@types/crypto-js": "^4.2.2", - "@types/lodash": "^4.14.178", "@types/node": "^18.14.0", "@types/nprogress": "^0.2.0", "@types/sortablejs": "^1.15.8", "@typescript-eslint/eslint-plugin": "^6.7.4", "@typescript-eslint/parser": "^6.7.4", - "@vitejs/plugin-vue": "^5.2.1", + "@vitejs/plugin-vue": "^5.2.3", "@vue/compiler-sfc": "^3.5.13", "code-inspector-plugin": "^0.4.5", "dotenv": "^16.3.1", "eslint": "^8.35.0", - "eslint-plugin-vue": "^9.31.0", + "eslint-plugin-vue": "^10.0.0", "prettier": "^3.2.5", - "sass": "^1.85.1", + "sass": "^1.86.3", "typescript": "^5.8.2", - "vite": "^6.2.1", + "vite": "^6.2.6", "vite-plugin-progress": "0.0.7", - "vue-eslint-parser": "^9.4.3" + "vue-eslint-parser": "^10.1.3" }, "browserslist": [ "> 1%", diff --git a/frontend/src/common/config.ts b/frontend/src/common/config.ts index a638a2ec..61a007a0 100644 --- a/frontend/src/common/config.ts +++ b/frontend/src/common/config.ts @@ -15,7 +15,7 @@ const config = { baseWsUrl: `${(window as any).globalConfig.BaseWsUrl || `${location.protocol == 'https:' ? 'wss:' : 'ws:'}//${getBaseApiUrl()}`}/api`, // 系统版本 - version: 'v1.9.3', + version: 'v1.9.4', }; export default config; diff --git a/frontend/src/common/rule.ts b/frontend/src/common/rule.ts index 242cc50c..f09b8a0a 100644 --- a/frontend/src/common/rule.ts +++ b/frontend/src/common/rule.ts @@ -23,7 +23,7 @@ export const Rules = { }, accountUsername: { - pattern: /^[a-zA-Z0-9_]{5,16}$/g, + pattern: /^[a-zA-Z0-9_.@:-]{5,16}$/, message: i18n.global.t('system.account.usernamePatternErrMsg'), trigger: 'blur', }, diff --git a/frontend/src/components/df/design.vue b/frontend/src/components/df/design.vue new file mode 100644 index 00000000..275adb0c --- /dev/null +++ b/frontend/src/components/df/design.vue @@ -0,0 +1,7 @@ + + diff --git a/frontend/src/components/enumselect/EnumSelect.vue b/frontend/src/components/enumselect/EnumSelect.vue index d37eaabc..759c183e 100644 --- a/frontend/src/components/enumselect/EnumSelect.vue +++ b/frontend/src/components/enumselect/EnumSelect.vue @@ -7,7 +7,7 @@ + diff --git a/frontend/src/views/msg/channel/ChannelEdit.vue b/frontend/src/views/msg/channel/ChannelEdit.vue new file mode 100755 index 00000000..a28e80cd --- /dev/null +++ b/frontend/src/views/msg/channel/ChannelEdit.vue @@ -0,0 +1,138 @@ + + + + diff --git a/frontend/src/views/msg/channel/ChannelEmail.vue b/frontend/src/views/msg/channel/ChannelEmail.vue new file mode 100755 index 00000000..d4be74e0 --- /dev/null +++ b/frontend/src/views/msg/channel/ChannelEmail.vue @@ -0,0 +1,16 @@ + + + + diff --git a/frontend/src/views/msg/channel/ChannelList.vue b/frontend/src/views/msg/channel/ChannelList.vue new file mode 100755 index 00000000..12cb1397 --- /dev/null +++ b/frontend/src/views/msg/channel/ChannelList.vue @@ -0,0 +1,113 @@ + + + + diff --git a/frontend/src/views/msg/components/MsgTmplSelect.vue b/frontend/src/views/msg/components/MsgTmplSelect.vue new file mode 100644 index 00000000..f4e7a0d5 --- /dev/null +++ b/frontend/src/views/msg/components/MsgTmplSelect.vue @@ -0,0 +1,47 @@ + + + diff --git a/frontend/src/views/msg/enums.ts b/frontend/src/views/msg/enums.ts new file mode 100644 index 00000000..56f546dd --- /dev/null +++ b/frontend/src/views/msg/enums.ts @@ -0,0 +1,24 @@ +import { EnumValue } from '@/common/Enum'; + +export const ChannelStatusEnum = { + Enable: EnumValue.of(1, 'common.enable').tagTypeSuccess(), + Disable: EnumValue.of(-1, 'common.disable').tagTypeDanger(), +}; + +export const TmplStatusEnum = { + Enable: EnumValue.of(1, 'common.enable').tagTypeSuccess(), + Disable: EnumValue.of(-1, 'common.disable').tagTypeDanger(), +}; + +export const TmplTypeEnum = { + Text: EnumValue.of(1, 'text'), + Markdown: EnumValue.of(2, 'markdown'), + Html: EnumValue.of(3, 'html'), +}; + +export const ChannelTypeEnum = { + Email: EnumValue.of('email', 'msg.email').setExtra({ component: 'ChannelEmail', msgTypes: [TmplTypeEnum.Text, TmplTypeEnum.Markdown, TmplTypeEnum.Html] }), + DingBot: EnumValue.of('dingBot', 'msg.dingBot').setExtra({ component: 'ChannelDing', msgTypes: [TmplTypeEnum.Text, TmplTypeEnum.Markdown] }), + QywxBot: EnumValue.of('qywxBot', 'msg.qywxBot').setExtra({ msgTypes: [TmplTypeEnum.Text.value, TmplTypeEnum.Markdown] }), + FeishuBot: EnumValue.of('feishuBot', 'msg.feishuBot').setExtra({ component: 'ChannelDing', msgTypes: [TmplTypeEnum.Text] }), +}; diff --git a/frontend/src/views/msg/tmpl/TmplEdit.vue b/frontend/src/views/msg/tmpl/TmplEdit.vue new file mode 100755 index 00000000..bb5ffc88 --- /dev/null +++ b/frontend/src/views/msg/tmpl/TmplEdit.vue @@ -0,0 +1,152 @@ + + + + diff --git a/frontend/src/views/msg/tmpl/TmplList.vue b/frontend/src/views/msg/tmpl/TmplList.vue new file mode 100755 index 00000000..b45e7038 --- /dev/null +++ b/frontend/src/views/msg/tmpl/TmplList.vue @@ -0,0 +1,180 @@ + + + + diff --git a/frontend/src/views/ops/component/ResourceOpPanel.vue b/frontend/src/views/ops/component/ResourceOpPanel.vue index 36de9700..ac8f6c72 100644 --- a/frontend/src/views/ops/component/ResourceOpPanel.vue +++ b/frontend/src/views/ops/component/ResourceOpPanel.vue @@ -21,7 +21,7 @@ const { width } = useWindowSize(); console.log(width); -const leftPaneSize = computed(() => (width.value >= 1600 ? 20 : 25)); +const leftPaneSize = computed(() => (width.value >= 1600 ? 20 : 24)); // 处理 resize 事件 const handleResize = (event: any) => { diff --git a/frontend/src/views/ops/db/DbTransferEdit.vue b/frontend/src/views/ops/db/DbTransferEdit.vue index bb0b07ba..60bad60b 100644 --- a/frontend/src/views/ops/db/DbTransferEdit.vue +++ b/frontend/src/views/ops/db/DbTransferEdit.vue @@ -157,11 +157,11 @@ import DbSelectTree from '@/views/ops/db/component/DbSelectTree.vue'; import CrontabInput from '@/components/crontab/CrontabInput.vue'; import { getDbDialect, getDbDialectMap } from '@/views/ops/db/dialect'; import SvgIcon from '@/components/svgIcon/index.vue'; -import _ from 'lodash'; import DrawerHeader from '@/components/drawer-header/DrawerHeader.vue'; import { useI18nFormValidate, useI18nSaveSuccessMsg } from '@/hooks/useI18n'; import { useI18n } from 'vue-i18n'; import { Rules } from '@/common/rule'; +import { deepClone } from '@/common/utils/object'; const { t } = useI18n(); @@ -274,7 +274,8 @@ watch(dialogVisible, async (newValue: boolean) => { }); return; } - state.form = _.cloneDeep(props.data) as FormData; + + state.form = deepClone(props.data) as FormData; let { srcDbId, targetDbId } = state.form; // 初始化src数据源 diff --git a/frontend/src/views/ops/db/db.ts b/frontend/src/views/ops/db/db.ts index 6750b5b0..bec1468f 100644 --- a/frontend/src/views/ops/db/db.ts +++ b/frontend/src/views/ops/db/db.ts @@ -464,7 +464,7 @@ export class DbInst { * @returns */ static isNumber(columnType: string) { - return columnType && columnType.match(/(int|uint|double|float|number|decimal|byte|bit)/gi); + return columnType && columnType.match(/(int|uint|double|float|number|numeric|decimal|byte|bit)/gi); } /** diff --git a/frontend/src/views/ops/machine/security/CmdConfList.vue b/frontend/src/views/ops/machine/security/CmdConfList.vue index 6cf57327..977e6017 100644 --- a/frontend/src/views/ops/machine/security/CmdConfList.vue +++ b/frontend/src/views/ops/machine/security/CmdConfList.vue @@ -102,9 +102,9 @@ import { TagResourceTypeEnum } from '@/common/commonEnum'; import { cmdConfApi } from '../api'; import DrawerHeader from '@/components/drawer-header/DrawerHeader.vue'; import TagCodePath from '../../component/TagCodePath.vue'; -import _ from 'lodash'; import { useI18nDeleteConfirm, useI18nDeleteSuccessMsg, useI18nFormValidate, useI18nSaveSuccessMsg } from '@/hooks/useI18n'; import { Rules } from '@/common/rule'; +import { deepClone } from '@/common/utils/object'; const rules = { tags: [Rules.requiredInput('machine.relateMachine')], @@ -166,7 +166,7 @@ const openFormDialog = (data: any) => { if (!data) { state.form = { ...DefaultForm }; } else { - state.form = _.cloneDeep(data); + state.form = deepClone(data); state.form.codePaths = data.tags?.map((tag: any) => tag.codePath); } state.dialogVisible = true; diff --git a/frontend/src/views/ops/redis/DataOperation.vue b/frontend/src/views/ops/redis/DataOperation.vue index 02862beb..82e3b531 100644 --- a/frontend/src/views/ops/redis/DataOperation.vue +++ b/frontend/src/views/ops/redis/DataOperation.vue @@ -44,12 +44,12 @@ - + diff --git a/server/go.mod b/server/go.mod index 62632c46..493f2771 100644 --- a/server/go.mod +++ b/server/go.mod @@ -3,7 +3,7 @@ module mayfly-go go 1.24 require ( - gitee.com/chunanyong/dm v1.8.18 + gitee.com/chunanyong/dm v1.8.19 gitee.com/liuzongyang/libpq v1.10.11 github.com/antlr4-go/antlr/v4 v4.13.1 github.com/emirpasic/gods v1.18.1 @@ -14,8 +14,8 @@ require ( github.com/go-playground/locales v0.14.1 github.com/go-playground/universal-translator v0.18.1 github.com/go-playground/validator/v10 v10.25.0 - github.com/go-sql-driver/mysql v1.9.0 - github.com/golang-jwt/jwt/v5 v5.2.1 + github.com/go-sql-driver/mysql v1.9.2 + github.com/golang-jwt/jwt/v5 v5.2.2 github.com/google/uuid v1.6.0 github.com/gorilla/websocket v1.5.3 github.com/lionsoul2014/ip2region/binding/golang v0.0.0-20241220152942-06eb5c6e8230 @@ -23,18 +23,18 @@ require ( github.com/microsoft/go-mssqldb v1.8.0 github.com/mojocn/base64Captcha v1.3.8 // 验证码 github.com/pkg/errors v0.9.1 - github.com/pkg/sftp v1.13.7 + github.com/pkg/sftp v1.13.9 github.com/pquerna/otp v1.4.0 - github.com/redis/go-redis/v9 v9.7.1 + github.com/redis/go-redis/v9 v9.7.3 github.com/robfig/cron/v3 v3.0.1 // 定时任务 github.com/sijms/go-ora/v2 v2.8.24 github.com/stretchr/testify v1.10.0 github.com/tidwall/gjson v1.18.0 github.com/veops/go-ansiterm v0.0.5 go.mongodb.org/mongo-driver v1.16.0 // mongo - golang.org/x/crypto v0.36.0 // ssh - golang.org/x/oauth2 v0.26.0 - golang.org/x/sync v0.12.0 + golang.org/x/crypto v0.37.0 // ssh + golang.org/x/oauth2 v0.29.0 + golang.org/x/sync v0.13.0 gopkg.in/natefinch/lumberjack.v2 v2.2.1 gopkg.in/yaml.v3 v3.0.1 // gorm @@ -93,8 +93,8 @@ require ( golang.org/x/exp v0.0.0-20250210185358-939b2ce775ac // indirect golang.org/x/image v0.23.0 // indirect golang.org/x/net v0.34.0 // indirect - golang.org/x/sys v0.31.0 // indirect - golang.org/x/text v0.23.0 // indirect + golang.org/x/sys v0.32.0 // indirect + golang.org/x/text v0.24.0 // indirect google.golang.org/protobuf v1.34.1 // indirect modernc.org/libc v1.22.5 // indirect modernc.org/mathutil v1.5.0 // indirect diff --git a/server/initialize/initialize.go b/server/initialize/initialize.go index 8ec2ad4a..241518c7 100644 --- a/server/initialize/initialize.go +++ b/server/initialize/initialize.go @@ -16,12 +16,12 @@ var ( initFuncs = make([]InitFunc, 0) ) -// 添加初始化ioc函数,由各个默认自行添加(直接init方法中ioc.Register注册不会打印ioc相关日志) +// 添加初始化ioc函数,由各个模块自行添加(直接init方法中ioc.Register注册不会打印ioc相关日志) func AddInitIocFunc(initIocFunc InitIocFunc) { initIocFuncs = append(initIocFuncs, initIocFunc) } -// 添加初始化函数,由各个默认自行添加 +// 添加初始化函数,由各个模块自行添加 func AddInitFunc(initFunc InitFunc) { initFuncs = append(initFuncs, initFunc) } diff --git a/server/internal/common/consts/consts.go b/server/internal/common/consts/consts.go index 19bfe952..34245f67 100644 --- a/server/internal/common/consts/consts.go +++ b/server/internal/common/consts/consts.go @@ -30,4 +30,5 @@ const ( ImsgNumDb = 60000 ImsgNumRedis = 70000 ImsgNumMongo = 80000 + ImsgNumMsg = 90000 ) diff --git a/server/internal/db/dbm/dbi/column.go b/server/internal/db/dbm/dbi/column.go index 84ac768f..c74f152a 100644 --- a/server/internal/db/dbm/dbi/column.go +++ b/server/internal/db/dbm/dbi/column.go @@ -226,9 +226,10 @@ var ( SQLValue: SQLValueNumeric, } + // 使用string进行转换,避免长度过长导致精度丢失等 DTNumeric = &DataType{ Name: "numeric", - Valuer: ValuerFloat64, + Valuer: ValuerString, SQLValue: SQLValueNumeric, } diff --git a/server/internal/db/dbm/mysql/transfer.go b/server/internal/db/dbm/mysql/transfer.go index 8ad70fa7..0b456005 100644 --- a/server/internal/db/dbm/mysql/transfer.go +++ b/server/internal/db/dbm/mysql/transfer.go @@ -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 } diff --git a/server/internal/event/topic.go b/server/internal/event/topic.go index 628c5a89..cfe8e3c2 100644 --- a/server/internal/event/topic.go +++ b/server/internal/event/topic.go @@ -1,6 +1,6 @@ package event const ( - EventTopicDeleteMachine = "machine:delete" // 删除机器的事件主题名 - EventTopicResourceOp = "resource:op" // 资源操作主题 + EventTopicResourceOp = "resource:op" // 资源操作主题 + EventTopicBizMsgTmplSend = "biz:msgtmpl:send" // 发送业务关联的消息模板 ) diff --git a/server/internal/flow/api/form/procdef.go b/server/internal/flow/api/form/procdef.go index 2d7be0ad..b80d20ee 100644 --- a/server/internal/flow/api/form/procdef.go +++ b/server/internal/flow/api/form/procdef.go @@ -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"` } diff --git a/server/internal/flow/api/procdef.go b/server/internal/flow/api/procdef.go index cefd7ad4..b6948597 100644 --- a/server/internal/flow/api/procdef.go +++ b/server/internal/flow/api/procdef.go @@ -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, })) } diff --git a/server/internal/flow/api/vo/procdef.go b/server/internal/flow/api/vo/procdef.go index 4387dd8d..374a6e73 100644 --- a/server/internal/flow/api/vo/procdef.go +++ b/server/internal/flow/api/vo/procdef.go @@ -8,6 +8,8 @@ import ( type Procdef struct { tagentity.RelateTags // 标签信息 entity.Procdef + + MsgTmplId *uint64 `json:"msgTmplId" gorm:"-"` // 消息模板ID } func (p *Procdef) GetRelateId() uint64 { diff --git a/server/internal/flow/application/const.go b/server/internal/flow/application/const.go new file mode 100644 index 00000000..0c103bfe --- /dev/null +++ b/server/internal/flow/application/const.go @@ -0,0 +1,5 @@ +package application + +const ( + FlowTaskNotifyBizKey = "flow:task:notify" // 工单任务处理通知 +) diff --git a/server/internal/flow/application/dto/dto.go b/server/internal/flow/application/dto/dto.go index aea8f053..7e6d5fba 100644 --- a/server/internal/flow/application/dto/dto.go +++ b/server/internal/flow/application/dto/dto.go @@ -4,6 +4,7 @@ import "mayfly-go/internal/flow/domain/entity" type SaveProcdef struct { Procdef *entity.Procdef + MsgTmplId uint64 // 消息模板id CodePaths []string } diff --git a/server/internal/flow/application/procdef.go b/server/internal/flow/application/procdef.go index dd1dcc71..80685ba0 100644 --- a/server/internal/flow/application/procdef.go +++ b/server/internal/flow/application/procdef.go @@ -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...) }) } diff --git a/server/internal/flow/application/procinst.go b/server/internal/flow/application/procinst.go index 67ad5dfa..5fcca094 100644 --- a/server/internal/flow/application/procinst.go +++ b/server/internal/flow/application/procinst.go @@ -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 } // 获取下一审批节点任务 diff --git a/server/internal/flow/domain/entity/query.go b/server/internal/flow/domain/entity/query.go index 333d861b..b47d2317 100644 --- a/server/internal/flow/domain/entity/query.go +++ b/server/internal/flow/domain/entity/query.go @@ -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"` // 状态 } diff --git a/server/internal/flow/infrastructure/persistence/procinst.go b/server/internal/flow/infrastructure/persistence/procinst.go index 434d0455..df452360 100644 --- a/server/internal/flow/infrastructure/persistence/procinst.go +++ b/server/internal/flow/infrastructure/persistence/procinst.go @@ -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) } diff --git a/server/internal/machine/api/machine_script.go b/server/internal/machine/api/machine_script.go index 28f09c57..01334a5b 100644 --- a/server/internal/machine/api/machine_script.go +++ b/server/internal/machine/api/machine_script.go @@ -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) diff --git a/server/internal/machine/application/application.go b/server/internal/machine/application/application.go index b6a84497..3e2c4dd5 100644 --- a/server/internal/machine/application/application.go +++ b/server/internal/machine/application/application.go @@ -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}) - }) })() } diff --git a/server/internal/machine/application/machine.go b/server/internal/machine/application/machine.go index 9fed1acd..df8b1e22 100644 --- a/server/internal/machine/application/machine.go +++ b/server/internal/machine/application/machine.go @@ -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{ diff --git a/server/internal/msg/api/api.go b/server/internal/msg/api/api.go index f9113efe..d199cb48 100644 --- a/server/internal/msg/api/api.go +++ b/server/internal/msg/api/api.go @@ -4,4 +4,6 @@ import "mayfly-go/pkg/ioc" func InitIoc() { ioc.Register(new(Msg)) + ioc.Register(new(MsgChannel)) + ioc.Register(new(MsgTmpl)) } diff --git a/server/internal/msg/api/form/msg.go b/server/internal/msg/api/form/msg.go new file mode 100644 index 00000000..82d705ac --- /dev/null +++ b/server/internal/msg/api/form/msg.go @@ -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"` +} diff --git a/server/internal/msg/api/msg_channel.go b/server/internal/msg/api/msg_channel.go new file mode 100644 index 00000000..c2a6f725 --- /dev/null +++ b/server/internal/msg/api/msg_channel.go @@ -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))) + } +} diff --git a/server/internal/msg/api/msg_tmpl.go b/server/internal/msg/api/msg_tmpl.go new file mode 100644 index 00000000..2192366d --- /dev/null +++ b/server/internal/msg/api/msg_tmpl.go @@ -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...)) +} diff --git a/server/internal/msg/application/application.go b/server/internal/msg/application/application.go index 2e0a3866..18fba691 100644 --- a/server/internal/msg/application/application.go +++ b/server/internal/msg/application/application.go @@ -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 { diff --git a/server/internal/msg/application/dto/msg.go b/server/internal/msg/application/dto/msg.go new file mode 100644 index 00000000..5ae8fc50 --- /dev/null +++ b/server/internal/msg/application/dto/msg.go @@ -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 +} diff --git a/server/internal/msg/application/msg_channel.go b/server/internal/msg/application/msg_channel.go new file mode 100644 index 00000000..26bdc6a7 --- /dev/null +++ b/server/internal/msg/application/msg_channel.go @@ -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 + }) +} diff --git a/server/internal/msg/application/msg_tmpl.go b/server/internal/msg/application/msg_tmpl.go new file mode 100644 index 00000000..b60b3709 --- /dev/null +++ b/server/internal/msg/application/msg_tmpl.go @@ -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}) +} diff --git a/server/internal/msg/application/msg_tmpl_biz.go b/server/internal/msg/application/msg_tmpl_biz.go new file mode 100644 index 00000000..8b4d40a7 --- /dev/null +++ b/server/internal/msg/application/msg_tmpl_biz.go @@ -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...) +} diff --git a/server/internal/msg/domain/entity/msg_channel.go b/server/internal/msg/domain/entity/msg_channel.go new file mode 100644 index 00000000..17f1f4d4 --- /dev/null +++ b/server/internal/msg/domain/entity/msg_channel.go @@ -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 // 禁用状态 +) diff --git a/server/internal/msg/domain/entity/msg_tmpl.go b/server/internal/msg/domain/entity/msg_tmpl.go new file mode 100644 index 00000000..50518881 --- /dev/null +++ b/server/internal/msg/domain/entity/msg_tmpl.go @@ -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" +} diff --git a/server/internal/msg/domain/entity/msg_tmpl_biz.go b/server/internal/msg/domain/entity/msg_tmpl_biz.go new file mode 100644 index 00000000..83e3ba71 --- /dev/null +++ b/server/internal/msg/domain/entity/msg_tmpl_biz.go @@ -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" +} diff --git a/server/internal/msg/domain/repository/msg_channel.go b/server/internal/msg/domain/repository/msg_channel.go new file mode 100644 index 00000000..cfd04aac --- /dev/null +++ b/server/internal/msg/domain/repository/msg_channel.go @@ -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) +} diff --git a/server/internal/msg/domain/repository/msg_tmpl.go b/server/internal/msg/domain/repository/msg_tmpl.go new file mode 100644 index 00000000..eaa4cc77 --- /dev/null +++ b/server/internal/msg/domain/repository/msg_tmpl.go @@ -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] +} diff --git a/server/internal/msg/domain/repository/msg_tmpl_biz.go b/server/internal/msg/domain/repository/msg_tmpl_biz.go new file mode 100644 index 00000000..a422f6cb --- /dev/null +++ b/server/internal/msg/domain/repository/msg_tmpl_biz.go @@ -0,0 +1,10 @@ +package repository + +import ( + "mayfly-go/internal/msg/domain/entity" + "mayfly-go/pkg/base" +) + +type MsgTmplBiz interface { + base.Repo[*entity.MsgTmplBiz] +} diff --git a/server/internal/msg/imsg/en.go b/server/internal/msg/imsg/en.go new file mode 100644 index 00000000..a555c175 --- /dev/null +++ b/server/internal/msg/imsg/en.go @@ -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", +} diff --git a/server/internal/msg/imsg/imsg.go b/server/internal/msg/imsg/imsg.go new file mode 100644 index 00000000..b95c9a9d --- /dev/null +++ b/server/internal/msg/imsg/imsg.go @@ -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 +) diff --git a/server/internal/msg/imsg/zh_cn.go b/server/internal/msg/imsg/zh_cn.go new file mode 100644 index 00000000..e7e1a592 --- /dev/null +++ b/server/internal/msg/imsg/zh_cn.go @@ -0,0 +1,12 @@ +package imsg + +import "mayfly-go/pkg/i18n" + +var Zh_CN = map[i18n.MsgId]string{ + LogMsgChannelSave: "消息渠道-保存", + LogMsgChannelDelete: "消息渠道-删除", + + LogMsgTmplSave: "消息模板-保存", + LogMsgTmplDelete: "消息模板-删除", + LogMsgTmplSend: "消息模板-发送", +} diff --git a/server/internal/msg/infrastructure/persistence/msg_channel.go b/server/internal/msg/infrastructure/persistence/msg_channel.go new file mode 100644 index 00000000..180565e5 --- /dev/null +++ b/server/internal/msg/infrastructure/persistence/msg_channel.go @@ -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) +} diff --git a/server/internal/msg/infrastructure/persistence/msg_tmpl.go b/server/internal/msg/infrastructure/persistence/msg_tmpl.go new file mode 100644 index 00000000..547390df --- /dev/null +++ b/server/internal/msg/infrastructure/persistence/msg_tmpl.go @@ -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{} +} diff --git a/server/internal/msg/infrastructure/persistence/msg_tmpl_biz.go b/server/internal/msg/infrastructure/persistence/msg_tmpl_biz.go new file mode 100644 index 00000000..06603892 --- /dev/null +++ b/server/internal/msg/infrastructure/persistence/msg_tmpl_biz.go @@ -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{} +} diff --git a/server/internal/msg/infrastructure/persistence/persistence.go b/server/internal/msg/infrastructure/persistence/persistence.go index 91c28722..838d5df2 100644 --- a/server/internal/msg/infrastructure/persistence/persistence.go +++ b/server/internal/msg/infrastructure/persistence/persistence.go @@ -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")) } diff --git a/server/internal/msg/init/init.go b/server/internal/msg/init/init.go index 220d1457..849ca8de 100644 --- a/server/internal/msg/init/init.go +++ b/server/internal/msg/init/init.go @@ -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) } diff --git a/server/internal/msg/msgx/msgx.go b/server/internal/msg/msgx/msgx.go new file mode 100644 index 00000000..18dd2248 --- /dev/null +++ b/server/internal/msg/msgx/msgx.go @@ -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 +} diff --git a/server/internal/msg/msgx/sender/ding_bot.go b/server/internal/msg/msgx/sender/ding_bot.go new file mode 100644 index 00000000..e0bb4682 --- /dev/null +++ b/server/internal/msg/msgx/sender/ding_bot.go @@ -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×tamp=%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 +} diff --git a/server/internal/msg/msgx/sender/email.go b/server/internal/msg/msgx/sender/email.go new file mode 100644 index 00000000..22dce464 --- /dev/null +++ b/server/internal/msg/msgx/sender/email.go @@ -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 +} diff --git a/server/internal/msg/msgx/sender/feishu_bot.go b/server/internal/msg/msgx/sender/feishu_bot.go new file mode 100644 index 00000000..ac22f3e2 --- /dev/null +++ b/server/internal/msg/msgx/sender/feishu_bot.go @@ -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 := `` + // 使用receiver参数替换消息内容中可能存在的接收人信息 + if len(msg.Receivers) > 0 { + if to := collx.ArrayMapFilter(msg.Receivers, func(a msgx.Receiver) (string, bool) { + if uid := a.GetExtraString("feishuUserId"); uid != "" { + // 使用 + return fmt.Sprintf(``, 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 +} diff --git a/server/internal/msg/msgx/sender/qywx_bot.go b/server/internal/msg/msgx/sender/qywx_bot.go new file mode 100644 index 00000000..a1a1dae3 --- /dev/null +++ b/server/internal/msg/msgx/sender/qywx_bot.go @@ -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 +} diff --git a/server/internal/msg/msgx/sender/sender.go b/server/internal/msg/msgx/sender/sender.go new file mode 100644 index 00000000..af384f48 --- /dev/null +++ b/server/internal/msg/msgx/sender/sender.go @@ -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{}) +} diff --git a/server/internal/sys/api/form/account.go b/server/internal/sys/api/form/account.go index 61020c0b..40db73b2 100644 --- a/server/internal/sys/api/form/account.go +++ b/server/internal/sys/api/form/account.go @@ -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"` } diff --git a/server/internal/sys/api/vo/account.go b/server/internal/sys/api/vo/account.go index 84ce8b4e..9e2f5f64 100644 --- a/server/internal/sys/api/vo/account.go +++ b/server/internal/sys/api/vo/account.go @@ -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:"-"` } diff --git a/server/internal/sys/application/syslog.go b/server/internal/sys/application/syslog.go index f1bee5ad..333498f4 100644 --- a/server/internal/sys/application/syslog.go +++ b/server/internal/sys/application/syslog.go @@ -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) } diff --git a/server/internal/sys/domain/entity/account.go b/server/internal/sys/domain/entity/account.go index ac02f881..bb649e11 100644 --- a/server/internal/sys/domain/entity/account.go +++ b/server/internal/sys/domain/entity/account.go @@ -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"` diff --git a/server/internal/tag/init/init.go b/server/internal/tag/init/init.go index 0e78f9b3..775dbc86 100644 --- a/server/internal/tag/init/init.go +++ b/server/internal/tag/init/init.go @@ -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) - } diff --git a/server/migration/migration.go b/server/migration/migration.go index 2f822a07..167b3c12 100644 --- a/server/migration/migration.go +++ b/server/migration/migration.go @@ -44,7 +44,7 @@ func run(db *gorm.DB, fs ...func() []*gormigrate.Migration) error { IDColumnName: "id", IDColumnSize: 300, UseTransaction: true, - ValidateUnknownMigrations: true, + ValidateUnknownMigrations: false, }, ms) if err := m.Migrate(); err != nil { return err diff --git a/server/migration/migrations/v1_9.go b/server/migration/migrations/v1_9.go index 62cf5612..d1226843 100644 --- a/server/migration/migrations/v1_9.go +++ b/server/migration/migrations/v1_9.go @@ -2,7 +2,11 @@ package migrations import ( machineentity "mayfly-go/internal/machine/domain/entity" + msgentity "mayfly-go/internal/msg/domain/entity" sysentity "mayfly-go/internal/sys/domain/entity" + "mayfly-go/pkg/logx" + "mayfly-go/pkg/model" + "time" "github.com/go-gormigrate/gormigrate/v2" "gorm.io/gorm" @@ -11,6 +15,7 @@ import ( func V1_9() []*gormigrate.Migration { var migrations []*gormigrate.Migration migrations = append(migrations, V1_9_3()...) + migrations = append(migrations, V1_9_4()...) return migrations } @@ -40,3 +45,167 @@ func V1_9_3() []*gormigrate.Migration { }, } } + +func V1_9_4() []*gormigrate.Migration { + return []*gormigrate.Migration{ + { + ID: "20250213-v1.9.4-addMsg", + Migrate: func(tx *gorm.DB) error { + tx.AutoMigrate(&sysentity.Account{}) + tx.AutoMigrate(&msgentity.MsgTmpl{}, &msgentity.MsgTmplChannel{}, &msgentity.MsgChannel{}, &msgentity.MsgTmplBiz{}) + + la := &model.LoginAccount{Id: 1, Username: "admin"} + // 创建审批默认消息模板 + processMsgTmplCode := "7u2MRCaB" + if err := tx.Where("code = ?", processMsgTmplCode).First(&msgentity.MsgTmpl{}).Error; err != nil { + tmplRemark := "工单审批通知模板" + msgTmpl := &msgentity.MsgTmpl{ + Code: processMsgTmplCode, + Name: "工单审批通知", + Tmpl: `{{.receiver}} +您有新的工单需要审批 +发起人:{{.creator}} +工单标题:{{.procdefName}} +备注:{{.procinstRemark}} +业务编号:{{.bizKey}}`, + Title: "工单审批", + MsgType: 1, + Status: 1, + Remark: &tmplRemark, + } + msgTmpl.FillBaseInfo(model.IdGenTypeNone, la) + if err := tx.Create(msgTmpl).Error; err != nil { + logx.ErrorTrace("create msg tmpl error", err) + return err + } + } + + resources := []*sysentity.Resource{ + { + Model: model.Model{CreateModel: model.CreateModel{DeletedModel: model.DeletedModel{IdModel: model.IdModel{Id: 1742816076}}}}, + Pid: 0, + UiPath: "ckg5ICnd/", + Name: "menu.msgManage", + Code: "/msg", + Type: 1, + Meta: `{"icon":"Message","isKeepAlive":true,"routeName":"msg"}`, + Weight: 60000000, + }, + { + Model: model.Model{CreateModel: model.CreateModel{DeletedModel: model.DeletedModel{IdModel: model.IdModel{Id: 1742816279}}}}, + Pid: 1742816076, + UiPath: "ckg5ICnd/eKQ8qAlH/", + Name: "menu.channel", + Code: "channels", + Type: 1, + Meta: `{"component":"msg/channel/ChannelList","icon":"Message","isKeepAlive":true,"routeName":"ChannelList"}`, + Weight: 1742816279, + }, + { + Model: model.Model{CreateModel: model.CreateModel{DeletedModel: model.DeletedModel{IdModel: model.IdModel{Id: 1742876893}}}}, + Pid: 1742816279, + UiPath: "ckg5ICnd/eKQ8qAlH/p2Xi8asv/", + Name: "menu.msgChannelBase", + Code: "msg:channel:base", + Type: 2, + Meta: ``, + Weight: 1742823660, + }, + { + Model: model.Model{CreateModel: model.CreateModel{DeletedModel: model.DeletedModel{IdModel: model.IdModel{Id: 1742823661}}}}, + Pid: 1742816279, + UiPath: "ckg5ICnd/eKQ8qAlH/Iu82rFKW/", + Name: "menu.saveMsgChannel", + Code: "msg:channel:save", + Type: 2, + Meta: ``, + Weight: 1742823661, + }, + { + Model: model.Model{CreateModel: model.CreateModel{DeletedModel: model.DeletedModel{IdModel: model.IdModel{Id: 1742826138}}}}, + Pid: 1742816279, + UiPath: "ckg5ICnd/eKQ8qAlH/Y4kRzNJp/", + Name: "menu.delMsgChannel", + Code: "msg:channel:del", + Type: 2, + Meta: ``, + Weight: 1742826138, + }, + { + Model: model.Model{CreateModel: model.CreateModel{DeletedModel: model.DeletedModel{IdModel: model.IdModel{Id: 1742876469}}}}, + Pid: 1742816076, + UiPath: "ckg5ICnd/XiJf38uW/", + Name: "menu.msgTmpl", + Code: "tmpls", + Type: 1, + Meta: `{"component":"msg/tmpl/TmplList","icon":"Message","isKeepAlive":true,"routeName":"TmplList"}`, + Weight: 1742876469, + }, + { + Model: model.Model{CreateModel: model.CreateModel{DeletedModel: model.DeletedModel{IdModel: model.IdModel{Id: 1742876795}}}}, + Pid: 1742876469, + UiPath: "ckg5ICnd/XiJf38uW/ExV9tz2l/", + Name: "menu.saveMsgTmpl", + Code: "msg:tmpl:save", + Type: 2, + Meta: ``, + Weight: 1742876795, + }, + { + Model: model.Model{CreateModel: model.CreateModel{DeletedModel: model.DeletedModel{IdModel: model.IdModel{Id: 1742876813}}}}, + Pid: 1742876469, + UiPath: "ckg5ICnd/XiJf38uW/2y7drhga/", + Name: "menu.delMsgTmpl", + Code: "msg:tmpl:del", + Type: 2, + Meta: ``, + Weight: 1742876813, + }, + { + Model: model.Model{CreateModel: model.CreateModel{DeletedModel: model.DeletedModel{IdModel: model.IdModel{Id: 1742876922}}}}, + Pid: 1742876469, + UiPath: "ckg5ICnd/XiJf38uW/VRX9YtM3/", + Name: "menu.msgTmplBase", + Code: "msg:tmpl:base", + Type: 2, + Meta: ``, + Weight: 1742876794, + }, + { + Model: model.Model{CreateModel: model.CreateModel{DeletedModel: model.DeletedModel{IdModel: model.IdModel{Id: 1742912893}}}}, + Pid: 1742876469, + UiPath: "ckg5ICnd/XiJf38uW/42PkAmLB/", + Name: "menu.sendMsg", + Code: "msg:tmpl:send", + Type: 2, + Meta: ``, + Weight: 1742912893, + }, + } + + now := time.Now() + for _, r := range resources { + if err := tx.Where("ui_path = ?", r.UiPath).First(&sysentity.Resource{}).Error; err == nil { + continue + } + r.Status = 1 + r.CreateTime = &now + r.UpdateTime = &now + r.Creator = la.Username + r.Modifier = la.Username + r.CreatorId = la.Id + r.ModifierId = la.Id + if err := tx.Create(r).Error; err != nil { + logx.ErrorTrace("create msg resource menu error", err) + return err + } + } + + return nil + }, + Rollback: func(tx *gorm.DB) error { + return nil + }, + }, + } +} diff --git a/server/pkg/config/app.go b/server/pkg/config/app.go index 60074797..fe2f6149 100644 --- a/server/pkg/config/app.go +++ b/server/pkg/config/app.go @@ -4,7 +4,7 @@ import "fmt" const ( AppName = "mayfly-go" - Version = "v1.9.3" + Version = "v1.9.4" ) func GetAppInfo() string { diff --git a/server/pkg/httpclient/httpclient.go b/server/pkg/httpx/httpx.go similarity index 68% rename from server/pkg/httpclient/httpclient.go rename to server/pkg/httpx/httpx.go index b4ee2bbb..ebe3a834 100644 --- a/server/pkg/httpclient/httpclient.go +++ b/server/pkg/httpx/httpx.go @@ -1,4 +1,4 @@ -package httpclient +package httpx import ( "bytes" @@ -20,7 +20,7 @@ import ( // 默认超时 const DefTimeout = 60 -type RequestWrapper struct { +type Req struct { client http.Client url string method string @@ -37,16 +37,16 @@ type MultipartFile struct { } // 创建一个请求 -func NewRequest(url string) *RequestWrapper { - return &RequestWrapper{url: url, client: http.Client{}} +func NewReq(url string) *Req { + return &Req{url: url, client: http.Client{}} } -func (r *RequestWrapper) Url(url string) *RequestWrapper { +func (r *Req) Url(url string) *Req { r.url = url return r } -func (r *RequestWrapper) Header(name, value string) *RequestWrapper { +func (r *Req) Header(name, value string) *Req { if r.header == nil { r.header = make(map[string]string) } @@ -54,12 +54,12 @@ func (r *RequestWrapper) Header(name, value string) *RequestWrapper { return r } -func (r *RequestWrapper) Timeout(timeout int) *RequestWrapper { +func (r *Req) Timeout(timeout int) *Req { r.timeout = timeout return r } -func (r *RequestWrapper) GetByQuery(queryMap collx.M) *ResponseWrapper { +func (r *Req) GetByQuery(queryMap collx.M) *Resp { var params string for k, v := range queryMap { if params != "" { @@ -71,13 +71,13 @@ func (r *RequestWrapper) GetByQuery(queryMap collx.M) *ResponseWrapper { return r.Get() } -func (r *RequestWrapper) Get() *ResponseWrapper { +func (r *Req) Get() *Resp { r.method = "GET" r.body = nil return sendRequest(r) } -func (r *RequestWrapper) PostJson(body string) *ResponseWrapper { +func (r *Req) PostJson(body string) *Resp { buf := bytes.NewBufferString(body) r.method = "POST" r.body = buf @@ -88,15 +88,15 @@ func (r *RequestWrapper) PostJson(body string) *ResponseWrapper { return sendRequest(r) } -func (r *RequestWrapper) PostObj(body any) *ResponseWrapper { +func (r *Req) PostObj(body any) *Resp { marshal, err := json.Marshal(body) if err != nil { - return &ResponseWrapper{err: errors.New("解析json obj错误")} + return &Resp{err: errors.New("解析json obj错误")} } return r.PostJson(string(marshal)) } -func (r *RequestWrapper) PostForm(params string) *ResponseWrapper { +func (r *Req) PostForm(params string) *Resp { buf := bytes.NewBufferString(params) r.method = "POST" r.body = buf @@ -107,7 +107,7 @@ func (r *RequestWrapper) PostForm(params string) *ResponseWrapper { return sendRequest(r) } -func (r *RequestWrapper) PostMulipart(files []MultipartFile, reqParams collx.M) *ResponseWrapper { +func (r *Req) PostMulipart(files []MultipartFile, reqParams collx.M) *Resp { buf := &bytes.Buffer{} // 文件写入 buf writer := multipart.NewWriter(buf) @@ -117,7 +117,7 @@ func (r *RequestWrapper) PostMulipart(files []MultipartFile, reqParams collx.M) if uploadFile.FilePath != "" { file, err := os.Open(uploadFile.FilePath) if err != nil { - return &ResponseWrapper{err: err} + return &Resp{err: err} } defer file.Close() reader = file @@ -127,18 +127,18 @@ func (r *RequestWrapper) PostMulipart(files []MultipartFile, reqParams collx.M) part, err := writer.CreateFormFile(uploadFile.FieldName, uploadFile.FileName) if err != nil { - return &ResponseWrapper{err: err} + return &Resp{err: err} } io.Copy(part, reader) } // 如果有其他参数,则写入body for k, v := range reqParams { if err := writer.WriteField(k, cast.ToString(v)); err != nil { - return &ResponseWrapper{err: err} + return &Resp{err: err} } } if err := writer.Close(); err != nil { - return &ResponseWrapper{err: err} + return &Resp{err: err} } r.method = "POST" @@ -150,8 +150,8 @@ func (r *RequestWrapper) PostMulipart(files []MultipartFile, reqParams collx.M) return sendRequest(r) } -func sendRequest(rw *RequestWrapper) *ResponseWrapper { - respWrapper := &ResponseWrapper{} +func sendRequest(rw *Req) *Resp { + respWrapper := &Resp{} timeout := rw.timeout if timeout > 0 { rw.client.Timeout = time.Duration(timeout) * time.Second @@ -166,37 +166,37 @@ func sendRequest(rw *RequestWrapper) *ResponseWrapper { } setRequestHeader(req, rw.header) resp, err := rw.client.Do(req) - return &ResponseWrapper{resp: resp, err: err} + return &Resp{resp: resp, err: err} } func setRequestHeader(req *http.Request, header map[string]string) { - req.Header.Set("User-Agent", "golang/mayfly") + req.Header.Set("User-Agent", "golang/mayfly-go") for k, v := range header { req.Header.Set(k, v) } } -type ResponseWrapper struct { +type Resp struct { resp *http.Response err error } -// 将响应体通过json解析转为指定结构体 -func (r *ResponseWrapper) BodyToObj(objPtr any) error { +// BodyTo 将响应体通过json解析转为指定结构体 +func (r *Resp) BodyTo(ptr any) error { bodyBytes, err := r.BodyBytes() if err != nil { return err } - err = json.Unmarshal(bodyBytes, &objPtr) + err = json.Unmarshal(bodyBytes, &ptr) if err != nil { return fmt.Errorf("解析响应体-json解析失败-%s", err.Error()) } return nil } -// 将响应体转为strings -func (r *ResponseWrapper) BodyToString() (string, error) { +// BodyToString 将响应体转为strings +func (r *Resp) BodyToString() (string, error) { bodyBytes, err := r.BodyBytes() if err != nil { return "", err @@ -204,21 +204,20 @@ func (r *ResponseWrapper) BodyToString() (string, error) { return string(bodyBytes), nil } -// 将响应体通过json解析转为map -func (r *ResponseWrapper) BodyToMap() (map[string]any, error) { +// BodyToMap 将响应体通过json解析转为map +func (r *Resp) BodyToMap() (map[string]any, error) { var res map[string]any - return res, r.BodyToObj(&res) + return res, r.BodyTo(&res) } -// 获取响应体的字节数组 -func (r *ResponseWrapper) BodyBytes() ([]byte, error) { - resp, err := r.GetHttpResp() +// BodyBytes 获取响应体的字节数组 +func (r *Resp) BodyBytes() ([]byte, error) { + bodyReader, err := r.BodyReader() if err != nil { return nil, err } - - body, err := io.ReadAll(resp.Body) - defer resp.Body.Close() + defer bodyReader.Close() + body, err := io.ReadAll(bodyReader) if err != nil { return nil, fmt.Errorf("读取响应体数据失败-%s", err.Error()) @@ -226,8 +225,18 @@ func (r *ResponseWrapper) BodyBytes() ([]byte, error) { return body, err } -// 获取http响应结果结构体 -func (r *ResponseWrapper) GetHttpResp() (*http.Response, error) { +// BodyReader 获取响应体的reader +func (r *Resp) BodyReader() (io.ReadCloser, error) { + resp, err := r.GetHttpResp() + if err != nil { + return nil, err + } + + return resp.Body, nil +} + +// GetHttpResp 获取http响应结果结构体 +func (r *Resp) GetHttpResp() (*http.Response, error) { if r.err != nil { return nil, fmt.Errorf("请求失败-%s", r.err.Error()) } diff --git a/server/pkg/httpclient/httpclient_test.go b/server/pkg/httpx/httpx_test.go similarity index 72% rename from server/pkg/httpclient/httpclient_test.go rename to server/pkg/httpx/httpx_test.go index 616aec1a..71ff13f8 100644 --- a/server/pkg/httpclient/httpclient_test.go +++ b/server/pkg/httpx/httpx_test.go @@ -1,4 +1,4 @@ -package httpclient +package httpx import ( "fmt" @@ -12,20 +12,20 @@ type TestStruct struct { } func TestGet(t *testing.T) { - res, err := NewRequest("www.baidu.com").Get().BodyToString() + res, err := NewReq("www.baidu.com").Get().BodyToString() fmt.Println(err) fmt.Println(res) } func TestGetBodyToMap(t *testing.T) { - res, err := NewRequest("http://go.mayfly.run/api/syslogs?pageNum=1&pageSize=10").Get().BodyToMap() + res, err := NewReq("http://go.mayfly.run/api/syslogs?pageNum=1&pageSize=10").Get().BodyToMap() fmt.Println(err) fmt.Println(res["msg"]) fmt.Println(res["code"]) } func TestGetQueryBodyToMap(t *testing.T) { - res, err := NewRequest("http://go.mayfly.run/api/syslogs"). + res, err := NewReq("http://go.mayfly.run/api/syslogs"). Header("Authorization", "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJleHAiOjE2OTUzOTQ5NTIsImlkIjoxLCJ1c2VybmFtZSI6ImFkbWluIn0.pGrczVZqk5nlId-FZPkjW_O5Sw3-2yjgzACp_j4JEXY"). GetByQuery(collx.M{"pageNum": 1, "pageSize": 10}). BodyToMap() diff --git a/server/pkg/model/model.go b/server/pkg/model/model.go index 5dd8e172..6aef8d8e 100644 --- a/server/pkg/model/model.go +++ b/server/pkg/model/model.go @@ -217,7 +217,7 @@ func (m Map[K, V]) Value() (driver.Value, error) { return json.Marshal(m) } -type Slice[T int | string | Map[string, any]] []T +type Slice[T int | uint64 | string | Map[string, any]] []T func (s *Slice[T]) Scan(value any) error { if v, ok := value.([]byte); ok && len(v) > 0 { @@ -254,3 +254,19 @@ func (e ExtraData) GetExtraString(key string) string { } return cast.ToString(e.Extra[key]) } + +// GetExtraInt 获取额外信息中的int类型字段值 +func (e ExtraData) GetExtraInt(key string) int { + if e.Extra == nil { + return 0 + } + return cast.ToInt(e.Extra[key]) +} + +// GetExtraBool 获取额外信息中的bool类型字段值 +func (e ExtraData) GetExtraBool(key string) bool { + if e.Extra == nil { + return false + } + return cast.ToBool(e.Extra[key]) +} diff --git a/server/pkg/utils/collx/array.go b/server/pkg/utils/collx/array.go index 8b44c02e..bd56ed83 100644 --- a/server/pkg/utils/collx/array.go +++ b/server/pkg/utils/collx/array.go @@ -59,8 +59,9 @@ func ArrayContains[T comparable](arr []T, el T) bool { return false } -// 数组转为map -// @param keyFunc key的主键 +// ArrayToMap 数组转为map +// +// keyFunc key的主键 func ArrayToMap[T any, K comparable](arr []T, keyFunc func(val T) K) map[K]T { res := make(map[K]T, len(arr)) for _, val := range arr { @@ -70,7 +71,7 @@ func ArrayToMap[T any, K comparable](arr []T, keyFunc func(val T) K) map[K]T { return res } -// 数组映射,即将一数组元素通过映射函数转换为另一数组 +// ArrayMap 数组映射,即将一数组元素通过映射函数转换为另一数组 func ArrayMap[T any, K any](arr []T, mapFunc func(val T) K) []K { res := make([]K, len(arr)) for i, val := range arr { @@ -79,6 +80,18 @@ func ArrayMap[T any, K any](arr []T, mapFunc func(val T) K) []K { return res } +// ArrayMapFilter 数组映射并过滤,若mapFunc返回false,则不映射该元素到新数组。 +func ArrayMapFilter[T any, K any](arr []T, mapFilterFunc func(val T) (K, bool)) []K { + res := make([]K, 0) + for _, val := range arr { + mapRes, needMap := mapFilterFunc(val) + if needMap { + res = append(res, mapRes) + } + } + return res +} + // 将数组或切片按固定大小分割成小数组 func ArrayChunk[T any](arr []T, chunkSize int) [][]T { var chunks [][]T diff --git a/server/pkg/utils/jsonx/jsonx.go b/server/pkg/utils/jsonx/jsonx.go index 2ef03a27..a6c6fb11 100644 --- a/server/pkg/utils/jsonx/jsonx.go +++ b/server/pkg/utils/jsonx/jsonx.go @@ -8,9 +8,9 @@ import ( ) // json字符串转map -func ToMap(jsonStr string) map[string]any { +func ToMap(jsonStr string) (map[string]any, error) { if jsonStr == "" { - return map[string]any{} + return map[string]any{}, nil } return ToMapByBytes([]byte(jsonStr)) } @@ -21,13 +21,10 @@ func To[T any](jsonStr string, res T) (T, error) { } // json字节数组转map -func ToMapByBytes(bytes []byte) map[string]any { +func ToMapByBytes(bytes []byte) (map[string]any, error) { var res map[string]any err := json.Unmarshal(bytes, &res) - if err != nil { - logx.ErrorTrace("json字符串转map失败", err) - } - return res + return res, err } // 转换为json字符串