From 81b8d2a93ded9cb20192461d4f2dabd1ad39fea8 Mon Sep 17 00:00:00 2001 From: engigu Date: Wed, 4 Mar 2026 21:45:43 +0800 Subject: [PATCH] feat: add message push function call --- go.mod | 24 +- go.sum | 186 ++++++++- internal/constant/constant.go | 20 + internal/controllers/auth_controller.go | 10 + .../controllers/notification_controller.go | 168 ++++++++ internal/controllers/settings_controller.go | 40 ++ internal/database/migrate.go | 2 + internal/middleware/notify_token.go | 38 ++ internal/models/notify_binding.go | 23 ++ internal/models/notify_way.go | 23 ++ internal/router/register.go | 31 +- internal/router/router.go | 28 +- internal/sdk/message/bark.go | 140 +++++++ internal/sdk/message/custom.go | 36 ++ internal/sdk/message/dtalk.go | 133 ++++++ internal/sdk/message/email.go | 46 +++ internal/sdk/message/feishu.go | 133 ++++++ internal/sdk/message/gotify.go | 67 +++ internal/sdk/message/ntfy.go | 76 ++++ internal/sdk/message/pushme.go | 51 +++ internal/sdk/message/qyweixin.go | 111 +++++ internal/sdk/message/telegram.go | 181 ++++++++ internal/sdk/message/wechat_account.go | 63 +++ internal/sdk/messenger/README.md | 161 ++++++++ internal/sdk/messenger/channels/aliyun_sms.go | 40 ++ internal/sdk/messenger/channels/bark.go | 34 ++ internal/sdk/messenger/channels/channel.go | 74 ++++ internal/sdk/messenger/channels/custom.go | 35 ++ internal/sdk/messenger/channels/dtalk.go | 41 ++ internal/sdk/messenger/channels/email.go | 49 +++ internal/sdk/messenger/channels/feishu.go | 43 ++ internal/sdk/messenger/channels/gotify.go | 34 ++ internal/sdk/messenger/channels/helpers.go | 52 +++ internal/sdk/messenger/channels/ntfy.go | 33 ++ internal/sdk/messenger/channels/pushme.go | 29 ++ internal/sdk/messenger/channels/qyweixin.go | 42 ++ internal/sdk/messenger/channels/telegram.go | 47 +++ internal/sdk/messenger/channels/types.go | 75 ++++ internal/sdk/messenger/channels/wechat.go | 38 ++ internal/sdk/messenger/messenger.go | 195 +++++++++ internal/services/notification_service.go | 323 +++++++++++++++ internal/services/settings_service.go | 5 + internal/services/tasks/executor_service.go | 46 +++ internal/services/tasks/task_service.go | 3 + web/src/api/index.ts | 55 +++ web/src/assets/index.css | 4 + web/src/layouts/MainLayout.vue | 3 +- web/src/router/index.ts | 1 + web/src/views/notify/Notify.vue | 387 ++++++++++++++++++ web/src/views/notify/components/ApiUsage.vue | 200 +++++++++ .../views/notify/components/ChannelDialog.vue | 137 +++++++ .../views/notify/components/ChannelList.vue | 82 ++++ .../views/notify/components/EventBinding.vue | 296 ++++++++++++++ 53 files changed, 4161 insertions(+), 33 deletions(-) create mode 100644 internal/controllers/notification_controller.go create mode 100644 internal/middleware/notify_token.go create mode 100644 internal/models/notify_binding.go create mode 100644 internal/models/notify_way.go create mode 100644 internal/sdk/message/bark.go create mode 100644 internal/sdk/message/custom.go create mode 100644 internal/sdk/message/dtalk.go create mode 100644 internal/sdk/message/email.go create mode 100644 internal/sdk/message/feishu.go create mode 100644 internal/sdk/message/gotify.go create mode 100644 internal/sdk/message/ntfy.go create mode 100644 internal/sdk/message/pushme.go create mode 100644 internal/sdk/message/qyweixin.go create mode 100644 internal/sdk/message/telegram.go create mode 100644 internal/sdk/message/wechat_account.go create mode 100644 internal/sdk/messenger/README.md create mode 100644 internal/sdk/messenger/channels/aliyun_sms.go create mode 100644 internal/sdk/messenger/channels/bark.go create mode 100644 internal/sdk/messenger/channels/channel.go create mode 100644 internal/sdk/messenger/channels/custom.go create mode 100644 internal/sdk/messenger/channels/dtalk.go create mode 100644 internal/sdk/messenger/channels/email.go create mode 100644 internal/sdk/messenger/channels/feishu.go create mode 100644 internal/sdk/messenger/channels/gotify.go create mode 100644 internal/sdk/messenger/channels/helpers.go create mode 100644 internal/sdk/messenger/channels/ntfy.go create mode 100644 internal/sdk/messenger/channels/pushme.go create mode 100644 internal/sdk/messenger/channels/qyweixin.go create mode 100644 internal/sdk/messenger/channels/telegram.go create mode 100644 internal/sdk/messenger/channels/types.go create mode 100644 internal/sdk/messenger/channels/wechat.go create mode 100644 internal/sdk/messenger/messenger.go create mode 100644 internal/services/notification_service.go create mode 100644 web/src/views/notify/Notify.vue create mode 100644 web/src/views/notify/components/ApiUsage.vue create mode 100644 web/src/views/notify/components/ChannelDialog.vue create mode 100644 web/src/views/notify/components/ChannelList.vue create mode 100644 web/src/views/notify/components/EventBinding.vue diff --git a/go.mod b/go.mod index d21aafe..d3e1d95 100644 --- a/go.mod +++ b/go.mod @@ -3,15 +3,22 @@ module github.com/engigu/baihu-panel go 1.25.0 require ( + github.com/aliyun/alibaba-cloud-sdk-go v1.63.107 github.com/creack/pty v1.1.24 github.com/gin-gonic/gin v1.9.1 github.com/glebarez/sqlite v1.11.0 + github.com/gofrs/flock v0.13.0 github.com/golang-jwt/jwt/v5 v5.3.0 github.com/gorilla/websocket v1.5.3 github.com/robfig/cron/v3 v3.0.1 + github.com/rs/xid v1.6.0 github.com/shirou/gopsutil/v3 v3.24.5 + github.com/silenceper/wechat/v2 v2.1.12 + github.com/sirupsen/logrus v1.9.4 go.uber.org/zap v1.27.1 + golang.org/x/net v0.47.0 golang.org/x/text v0.32.0 + gopkg.in/gomail.v2 v2.0.0-20160411212932-81ebce5c23df gopkg.in/ini.v1 v1.67.0 gopkg.in/natefinch/lumberjack.v2 v2.2.1 gorm.io/driver/mysql v1.6.0 @@ -26,10 +33,14 @@ require ( github.com/andybalholm/brotli v1.2.0 // indirect github.com/bep/godartsass/v2 v2.5.0 // indirect github.com/bep/golibsass v1.2.0 // indirect + github.com/bradfitz/gomemcache v0.0.0-20220106215444-fb4bf637b56d // indirect github.com/bytedance/sonic v1.9.1 // indirect + github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/chenzhuoyu/base64x v0.0.0-20221115062448-fe3a3abad311 // indirect + github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect github.com/dustin/go-humanize v1.0.1 // indirect github.com/fatih/color v1.18.0 // indirect + github.com/fatih/structs v1.1.0 // indirect github.com/fsnotify/fsnotify v1.9.0 // indirect github.com/gabriel-vasile/mimetype v1.4.2 // indirect github.com/gin-contrib/sse v0.1.0 // indirect @@ -38,19 +49,19 @@ require ( github.com/go-playground/locales v0.14.1 // indirect github.com/go-playground/universal-translator v0.18.1 // indirect github.com/go-playground/validator/v10 v10.14.0 // indirect + github.com/go-redis/redis/v8 v8.11.5 // indirect github.com/go-sql-driver/mysql v1.9.3 // indirect github.com/gobwas/glob v0.2.3 // indirect github.com/goccy/go-json v0.10.2 // indirect - github.com/gofrs/flock v0.13.0 // indirect github.com/gohugoio/hugo v0.149.1 // indirect github.com/google/uuid v1.6.0 // indirect - github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/jackc/pgpassfile v1.0.0 // indirect github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect github.com/jackc/pgx/v5 v5.7.6 // indirect github.com/jackc/puddle/v2 v2.2.2 // indirect github.com/jinzhu/inflection v1.0.0 // indirect github.com/jinzhu/now v1.1.5 // indirect + github.com/jmespath/go-jmespath v0.4.0 // indirect github.com/joho/godotenv v1.5.1 // indirect github.com/json-iterator/go v1.1.12 // indirect github.com/klauspost/cpuid/v2 v2.2.4 // indirect @@ -60,17 +71,18 @@ require ( github.com/mattn/go-isatty v0.0.20 // indirect github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect github.com/modern-go/reflect2 v1.0.2 // indirect + github.com/opentracing/opentracing-go v1.2.1-0.20220228012449-10b1cf09e00b // indirect github.com/pelletier/go-toml v1.9.5 // indirect github.com/pelletier/go-toml/v2 v2.2.4 // indirect github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c // indirect github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect - github.com/rs/xid v1.6.0 // indirect github.com/shoenig/go-m1cpu v0.1.6 // indirect github.com/spf13/afero v1.14.0 // indirect github.com/spf13/cast v1.9.2 // indirect - github.com/spf13/cobra v1.10.2 // indirect - github.com/spf13/pflag v1.0.9 // indirect github.com/tdewolff/parse/v2 v2.8.3 // indirect + github.com/tidwall/gjson v1.14.1 // indirect + github.com/tidwall/match v1.1.1 // indirect + github.com/tidwall/pretty v1.2.0 // indirect github.com/tklauser/go-sysconf v0.3.12 // indirect github.com/tklauser/numcpus v0.6.1 // indirect github.com/twitchyliquid64/golang-asm v0.15.1 // indirect @@ -79,10 +91,10 @@ require ( go.uber.org/multierr v1.10.0 // indirect golang.org/x/arch v0.3.0 // indirect golang.org/x/crypto v0.46.0 // indirect - golang.org/x/net v0.47.0 // indirect golang.org/x/sync v0.19.0 // indirect golang.org/x/sys v0.39.0 // indirect google.golang.org/protobuf v1.36.8 // indirect + gopkg.in/alexcesaro/quotedprintable.v3 v3.0.0-20150716171945-2caba252f4dc // indirect gopkg.in/yaml.v3 v3.0.1 // indirect modernc.org/libc v1.22.5 // indirect modernc.org/mathutil v1.5.0 // indirect diff --git a/go.sum b/go.sum index 433deee..f835c39 100644 --- a/go.sum +++ b/go.sum @@ -1,13 +1,23 @@ dario.cat/mergo v1.0.2 h1:85+piFYR1tMbRrLcDwR18y4UKJ3aH1Tbzi24VRW1TK8= dario.cat/mergo v1.0.2/go.mod h1:E/hbnu0NxMFBjpMIE34DRGLWqDy0g5FuKDhCb31ngxA= +dmitri.shuralyov.com/gpu/mtl v0.0.0-20190408044501-666a987793e9/go.mod h1:H6x//7gZCb22OMCxBHrMx7a5I7Hp++hsVxbQ4BYO7hU= filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA= filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4= github.com/BurntSushi/locker v0.0.0-20171006230638-a6e239ea1c69 h1:+tu3HOoMXB7RXEINRVIpxJCT+KdYiI7LAEAUrOw3dIU= github.com/BurntSushi/locker v0.0.0-20171006230638-a6e239ea1c69/go.mod h1:L1AbZdiDllfyYH5l5OkAaZtk7VkWe89bPJFmnDBNHxg= +github.com/BurntSushi/xgb v0.0.0-20160522181843-27f122750802/go.mod h1:IVnqGOEym/WlBOVXweHU+Q+/VP0lqqI8lqeDx9IjBqo= +github.com/HdrHistogram/hdrhistogram-go v1.1.2/go.mod h1:yDgFjdqOqDEKOvasDdhWNXYg9BVp4O+o5f6V/ehm6Oo= github.com/air-verse/air v1.64.5 h1:+gs/NgTzYYe+gGPyfHy3XxpJReQWC1pIsiKIg0LgNt4= github.com/air-verse/air v1.64.5/go.mod h1:OaJZSfZqf7wyjS2oP/CcEVyIt0JmZuPh5x1gdtklmmY= +github.com/ajstarks/svgo v0.0.0-20180226025133-644b8db467af/go.mod h1:K08gAheRH3/J6wwsYMMT4xOr94bZjxIelGM0+d/wbFw= github.com/alecthomas/chroma/v2 v2.20.0 h1:sfIHpxPyR07/Oylvmcai3X/exDlE8+FA820NTz+9sGw= github.com/alecthomas/chroma/v2 v2.20.0/go.mod h1:e7tViK0xh/Nf4BYHl00ycY6rV7b8iXBksI9E359yNmA= +github.com/alicebob/gopher-json v0.0.0-20200520072559-a9ecdc9d1d3a h1:HbKu58rmZpUGpz5+4FfNmIU+FmZg2P3Xaj2v2bfNWmk= +github.com/alicebob/gopher-json v0.0.0-20200520072559-a9ecdc9d1d3a/go.mod h1:SGnFV6hVsYE877CKEZ6tDNTjaSXYUk6QqoIK6PrAtcc= +github.com/alicebob/miniredis/v2 v2.30.0 h1:uA3uhDbCxfO9+DI/DuGeAMr9qI+noVWwGPNTFuKID5M= +github.com/alicebob/miniredis/v2 v2.30.0/go.mod h1:84TWKZlxYkfgMucPBf5SOQBYJceZeQRFIaQgNMiCX6Q= +github.com/aliyun/alibaba-cloud-sdk-go v1.63.107 h1:qagvUyrgOnBIlVRQWOyCZGVKUIYbMBdGdJ104vBpRFU= +github.com/aliyun/alibaba-cloud-sdk-go v1.63.107/go.mod h1:SOSDHfe1kX91v3W5QiBsWSLqeLxImobbMX1mxrFHsVQ= github.com/andybalholm/brotli v1.2.0 h1:ukwgCxwYrmACq68yiUqwIWnGY0cTPox/M94sVwToPjQ= github.com/andybalholm/brotli v1.2.0/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY= github.com/armon/go-radix v1.0.1-0.20221118154546-54df44f2176c h1:651/eoCRnQ7YtSjAnSzRucrJz+3iGEFt+ysraELS81M= @@ -42,22 +52,30 @@ github.com/bep/overlayfs v0.10.0 h1:wS3eQ6bRsLX+4AAmwGjvoFSAQoeheamxofFiJ2SthSE= github.com/bep/overlayfs v0.10.0/go.mod h1:ouu4nu6fFJaL0sPzNICzxYsBeWwrjiTdFZdK4lI3tro= github.com/bep/tmc v0.5.1 h1:CsQnSC6MsomH64gw0cT5f+EwQDcvZz4AazKunFwTpuI= github.com/bep/tmc v0.5.1/go.mod h1:tGYHN8fS85aJPhDLgXETVKp+PR382OvFi2+q2GkGsq0= +github.com/bradfitz/gomemcache v0.0.0-20220106215444-fb4bf637b56d h1:pVrfxiGfwelyab6n21ZBkbkmbevaf+WvMIiR7sr97hw= +github.com/bradfitz/gomemcache v0.0.0-20220106215444-fb4bf637b56d/go.mod h1:H0wQNHz2YrLsuXOZozoeDmnHXkNCRmMW0gwFWDfEZDA= github.com/bytedance/sonic v1.5.0/go.mod h1:ED5hyg4y6t3/9Ku1R6dU/4KyJ48DZ4jPhfY1O2AihPM= github.com/bytedance/sonic v1.9.1 h1:6iJ6NqdoxCDr6mbY8h18oSO+cShGSMRGCEo7F2h0x8s= github.com/bytedance/sonic v1.9.1/go.mod h1:i736AoUSYt75HyZLoJW9ERYxcy6eaN6h4BZXU064P/U= +github.com/cespare/xxhash/v2 v2.1.2/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= github.com/chenzhuoyu/base64x v0.0.0-20211019084208-fb5309c8db06/go.mod h1:DH46F32mSOjUmXrMHnKwZdA8wcEefY7UVqBKYGjpdQY= github.com/chenzhuoyu/base64x v0.0.0-20221115062448-fe3a3abad311 h1:qSGYFH7+jGhDF8vLC+iwCD4WpbV1EBDSzWkJODFLams= github.com/chenzhuoyu/base64x v0.0.0-20221115062448-fe3a3abad311/go.mod h1:b583jCggY9gE99b6G5LEC39OIiVsWj+R97kbl5odCEk= +github.com/chzyer/logex v1.1.10/go.mod h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWRnGsAI= +github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e/go.mod h1:nSuG5e5PlCu98SY8svDHJxuZscDgtXS6KTTbou5AhLI= +github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU= github.com/clbanning/mxj/v2 v2.7.0 h1:WA/La7UGCanFe5NpHF0Q3DNtnCsVoxbPKuyBNHWRyME= github.com/clbanning/mxj/v2 v2.7.0/go.mod h1:hNiWqW14h+kc+MdF9C6/YoRfjEJoR3ou6tn/Qo+ve2s= -github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= +github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s= github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78= +github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc= github.com/disintegration/gift v1.2.1 h1:Y005a1X4Z7Uc+0gLpSAsKhWi4qLtsdEcMIbbdvdZ6pc= github.com/disintegration/gift v1.2.1/go.mod h1:Jh2i7f7Q2BM7Ezno3PhfezbR1xpUg9dUg3/RlKGr4HI= github.com/dlclark/regexp2 v1.11.5 h1:Q/sSnsKerHeCkc/jSTNq1oCm7KiVgUMZRDUoRu0JQZQ= @@ -68,9 +86,14 @@ github.com/evanw/esbuild v0.25.9 h1:aU7GVC4lxJGC1AyaPwySWjSIaNLAdVEEuq3chD0Khxs= github.com/evanw/esbuild v0.25.9/go.mod h1:D2vIQZqV/vIf/VRHtViaUtViZmG7o+kKmlBfVQuRi48= github.com/fatih/color v1.18.0 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM= github.com/fatih/color v1.18.0/go.mod h1:4FelSpRwEGDpQ12mAdzqdOukCy4u8WUtOY6lkT/6HfU= +github.com/fatih/structs v1.1.0 h1:Q7juDM0QtcnhCpeyLGQKyg4TOIghuNXrkL32pHAUMxo= +github.com/fatih/structs v1.1.0/go.mod h1:9NiDSp5zOcgEDl+j00MP/WkGVPOlPRLejGD8Ga6PJ7M= +github.com/fogleman/gg v1.2.1-0.20190220221249-0403632d5b90/go.mod h1:R/bRT+9gY/C5z7JzPU0zXsXHKM4/ayA+zqcVNZzPa1k= github.com/frankban/quicktest v1.7.2/go.mod h1:jaStnuzAqU1AJdCO0l53JDCJrVDKcS03DbaAcR7Ks/o= github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= +github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo= +github.com/fsnotify/fsnotify v1.4.9/go.mod h1:znqG4EE+3YCdAaPaxE2ZRY/06pZUdp0tY4IgpuI1SZQ= github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k= github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= github.com/gabriel-vasile/mimetype v1.4.2 h1:w5qFW6JKBz9Y393Y4q372O9A7cUSequkh1Q7OhCmWKU= @@ -87,6 +110,7 @@ github.com/glebarez/go-sqlite v1.21.2 h1:3a6LFC4sKahUunAmynQKLZceZCOzUthkRkEAl9g github.com/glebarez/go-sqlite v1.21.2/go.mod h1:sfxdZyhQjTM2Wry3gVYWaW072Ri1WMdWJi0k6+3382k= github.com/glebarez/sqlite v1.11.0 h1:wSG0irqzP6VurnMEpFGer5Li19RpIRi2qvQz++w0GMw= github.com/glebarez/sqlite v1.11.0/go.mod h1:h8/o8j5wiAsqSPoWELDUdJXhjAhsVliSn7bWZjOhrgQ= +github.com/go-gl/glfw v0.0.0-20190409004039-e6da0acd62b1/go.mod h1:vR7hzQXu2zJy9AVAgeJqvqgH9Q5CA+iKCZ2gyEVpxRU= github.com/go-ole/go-ole v1.2.6 h1:/Fpf6oFPoeFik9ty7siob0G6Ke8QvQEuVcuChpwXzpY= github.com/go-ole/go-ole v1.2.6/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0= github.com/go-openapi/jsonpointer v0.21.0 h1:YgdVicSA9vH5RiHs9TZW5oyafXZFc6+2Vc1rr/O9oNQ= @@ -101,8 +125,11 @@ github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJn github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY= github.com/go-playground/validator/v10 v10.14.0 h1:vgvQWe3XCz3gIeFDm/HnTIbj6UGmg/+t63MyGU2n5js= github.com/go-playground/validator/v10 v10.14.0/go.mod h1:9iXMNT7sEkjXb0I+enO7QXmzG6QCsPWY4zveKFVRSyU= +github.com/go-redis/redis/v8 v8.11.5 h1:AcZZR7igkdvfVmQTPnu9WE37LRrO/YrBH5zWyjDC0oI= +github.com/go-redis/redis/v8 v8.11.5/go.mod h1:gREzHqY1hg6oD9ngVRbLStwAWKhA0FEgq8Jd4h5lpwo= github.com/go-sql-driver/mysql v1.9.3 h1:U/N249h2WzJ3Ukj8SowVFjdtZKfu9vlLZxjPXV1aweo= github.com/go-sql-driver/mysql v1.9.3/go.mod h1:qn46aNg1333BRMNU69Lq93t8du/dwxI64Gl8i5p1WMU= +github.com/go-task/slim-sprig v0.0.0-20210107165309-348f09dbbbc0/go.mod h1:fyg7847qk6SyHyPtNmDHnmrv/HOrqktSC+C9fM+CJOE= github.com/gobuffalo/flect v1.0.3 h1:xeWBM2nui+qnVvNM4S3foBhCAL2XgPU+a7FdpelbTq4= github.com/gobuffalo/flect v1.0.3/go.mod h1:A5msMlrHtLqh9umBSnvabjsMrCcCpAyzglnDvkbYKHs= github.com/gobwas/glob v0.2.3 h1:A4xDbljILXROh+kObIiy5kIaPYD8e96x1tgBhUI5J+Y= @@ -127,13 +154,29 @@ github.com/gohugoio/locales v0.14.0 h1:Q0gpsZwfv7ATHMbcTNepFd59H7GoykzWJIxi113XG github.com/gohugoio/locales v0.14.0/go.mod h1:ip8cCAv/cnmVLzzXtiTpPwgJ4xhKZranqNqtoIu0b/4= github.com/gohugoio/localescompressed v1.0.1 h1:KTYMi8fCWYLswFyJAeOtuk/EkXR/KPTHHNN9OS+RTxo= github.com/gohugoio/localescompressed v1.0.1/go.mod h1:jBF6q8D7a0vaEmcWPNcAjUZLJaIVNiwvM3WlmTvooB0= +github.com/goji/httpauth v0.0.0-20160601135302-2da839ab0f4d/go.mod h1:nnjvkQ9ptGaCkuDUx6wNykzzlUixGxvkme+H/lnzb+A= github.com/golang-jwt/jwt/v5 v5.3.0 h1:pv4AsKCKKZuqlgs5sUmn4x8UlGa0kEVt/puTpKx9vvo= github.com/golang-jwt/jwt/v5 v5.3.0/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= +github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0/go.mod h1:E/TSTwGwJL78qG/PmXZO1EjYhfJinVAhrmmHX6Z8B9k= +github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= +github.com/golang/protobuf v1.4.0-rc.1/go.mod h1:ceaxUfeHdC40wWswd/P6IGgMaK3YpKi5j83Wpe3EHw8= +github.com/golang/protobuf v1.4.0-rc.1.0.20200221234624-67d41d38c208/go.mod h1:xKAWHe0F5eneWXFV3EuXVDTCmh+JuBKY0li0aMyXATA= +github.com/golang/protobuf v1.4.0-rc.2/go.mod h1:LlEzMj4AhA7rCAGe4KMBDvJI+AwstrUpVNzEA03Pprs= +github.com/golang/protobuf v1.4.0-rc.4.0.20200313231945-b860323f09d0/go.mod h1:WU3c8KckQ9AFe+yFwt9sWVRKCVIyN9cPHBJSNnbL67w= +github.com/golang/protobuf v1.4.0/go.mod h1:jodUvKwWbYaEsadDk5Fwe5c77LiNKVO9IDvqG2KuDX0= +github.com/golang/protobuf v1.4.2/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI= +github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk= +github.com/golang/protobuf v1.5.2/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY= +github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= +github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.5.4/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= +github.com/google/pprof v0.0.0-20210407192527-94a9f03dee38/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE= github.com/google/pprof v0.0.0-20221118152302-e6195bd50e26 h1:Xim43kblpZXfIBQsbuBVKCudVG457BR2GZFIz3uw3hQ= github.com/google/pprof v0.0.0-20221118152302-e6195bd50e26/go.mod h1:dDKJzRmX4S37WGHujM7tX//fmj1uioxKzKxz3lo4HJo= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= @@ -142,12 +185,14 @@ github.com/gorilla/css v1.0.1 h1:ntNaBIghp6JmvWnxbZKANoLyuXTPZ4cAMlo6RyhlbO8= github.com/gorilla/css v1.0.1/go.mod h1:BvnYkspnSzMmwRK+b8/xgNPLiIuNZr6vbZBTPQ2A3b0= github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg= github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= +github.com/h2non/parth v0.0.0-20190131123155-b4df798d6542 h1:2VTzZjLZBgl62/EtslCrtky5vbi9dd7HrQPQIx6wqiw= +github.com/h2non/parth v0.0.0-20190131123155-b4df798d6542/go.mod h1:Ow0tF8D4Kplbc8s8sSb3V2oUCygFHVp8gC3Dn6U4MNI= github.com/hairyhenderson/go-codeowners v0.7.0 h1:s0W4wF8bdsBEjTWzwzSlsatSthWtTAF2xLgo4a4RwAo= github.com/hairyhenderson/go-codeowners v0.7.0/go.mod h1:wUlNgQ3QjqC4z8DnM5nnCYVq/icpqXJyJOukKx5U8/Q= github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k= github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM= -github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= -github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= +github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU= +github.com/ianlancetaylor/demangle v0.0.0-20200824232613-28f6c0f3b639/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc= github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo= @@ -162,12 +207,17 @@ github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc= github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ= github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8= +github.com/jmespath/go-jmespath v0.4.0 h1:BEgLn5cpjn8UN1mAw4NjwDrS35OdebyEtFe+9YPoQUg= +github.com/jmespath/go-jmespath v0.4.0/go.mod h1:T8mJZnbsbmF+m6zOOFylbeCJqk5+pHWvzYPziyZiYoo= +github.com/jmespath/go-jmespath/internal/testify v1.5.1 h1:shLQSRRSCCPj3f2gpwzGwWFoC7ycTf1rcQZHOlsJ6N8= +github.com/jmespath/go-jmespath/internal/testify v1.5.1/go.mod h1:L3OGu8Wl2/fWfCI6z80xFu9LTZmf1ZRjMHUOPmWr69U= github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY= github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y= github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= +github.com/jung-kurt/gofpdf v1.0.3-0.20190309125859-24315acbbda5/go.mod h1:7Id9E/uU8ce6rXgefFLlgrJj/GYY22cpxn+r32jIOes= github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg= github.com/klauspost/cpuid/v2 v2.2.4 h1:acbojRNwl3o09bUq+yDCtZFc1aiwaAAxtcn8YkZXnvk= github.com/klauspost/cpuid/v2 v2.2.4/go.mod h1:RVVoqg1df56z8g3pUjL/3lE5UfnlrJX8tyFgg4nqhuY= @@ -209,8 +259,13 @@ github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826 h1:RWengNIwukTxcDr9 github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826/go.mod h1:TaXosZuwdSHYgviHp1DAtfrULt5eUgsSMsZf+YrPgl8= github.com/muesli/smartcrop v0.3.0 h1:JTlSkmxWg/oQ1TcLDoypuirdE8Y/jzNirQeLkxpA6Oc= github.com/muesli/smartcrop v0.3.0/go.mod h1:i2fCI/UorTfgEpPPLWiFBv4pye+YAG78RwcQLUkocpI= +github.com/nbio/st v0.0.0-20140626010706-e9e8d9816f32/go.mod h1:9wM+0iRr9ahx58uYLpLIr5fm8diHn0JbqRycJi6w0Ms= +github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno= github.com/niklasfasching/go-org v1.9.1 h1:/3s4uTPOF06pImGa2Yvlp24yKXZoTYM+nsIlMzfpg/0= github.com/niklasfasching/go-org v1.9.1/go.mod h1:ZAGFFkWvUQcpazmi/8nHqwvARpr1xpb+Es67oUGX/48= +github.com/nxadm/tail v1.4.4/go.mod h1:kenIhsEOeOJmVchQTgglprH7qJGnHDVpk1VPCcaMI8A= +github.com/nxadm/tail v1.4.8 h1:nPr65rt6Y5JFSKQO7qToXr7pePgD6Gwiw05lkbyAQTE= +github.com/nxadm/tail v1.4.8/go.mod h1:+ncqLTQzXmGhMZNUePPaPqPvBxHAIsmXswZKocGu+AU= github.com/oasdiff/yaml v0.0.0-20250309154309-f31be36b4037 h1:G7ERwszslrBzRxj//JalHPu/3yz+De2J+4aLtSRlHiY= github.com/oasdiff/yaml v0.0.0-20250309154309-f31be36b4037/go.mod h1:2bpvgLBZEtENV5scfDFEtB/5+1M4hkQhDQrccEJ/qGw= github.com/oasdiff/yaml3 v0.0.0-20250309153720-d2182401db90 h1:bQx3WeLcUWy+RletIKwUIt4x3t8n2SxavmoclizMb8c= @@ -221,6 +276,19 @@ github.com/olekukonko/ll v0.0.9 h1:Y+1YqDfVkqMWuEQMclsF9HUR5+a82+dxJuL1HHSRpxI= github.com/olekukonko/ll v0.0.9/go.mod h1:En+sEW0JNETl26+K8eZ6/W4UQ7CYSrrgg/EdIYT2H8g= github.com/olekukonko/tablewriter v1.0.9 h1:XGwRsYLC2bY7bNd93Dk51bcPZksWZmLYuaTHR0FqfL8= github.com/olekukonko/tablewriter v1.0.9/go.mod h1:5c+EBPeSqvXnLLgkm9isDdzR3wjfBkHR9Nhfp3NWrzo= +github.com/onsi/ginkgo v1.6.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE= +github.com/onsi/ginkgo v1.12.1/go.mod h1:zj2OWP4+oCPe1qIXoGWkgMRwljMUYCdkwsT2108oapk= +github.com/onsi/ginkgo v1.16.4/go.mod h1:dX+/inL/fNMqNlz0e9LfyB9TswhZpCVdJM/Z6Vvnwo0= +github.com/onsi/ginkgo v1.16.5 h1:8xi0RTUf59SOSfEtZMvwTvXYMzG4gV23XVHOZiXNtnE= +github.com/onsi/ginkgo v1.16.5/go.mod h1:+E8gABHa3K6zRBolWtd+ROzc/U5bkGt0FwiG042wbpU= +github.com/onsi/ginkgo/v2 v2.0.0/go.mod h1:vw5CSIxN1JObi/U8gcbwft7ZxR2dgaR70JSE3/PpL4c= +github.com/onsi/gomega v1.7.1/go.mod h1:XdKZgCCFLUoM/7CFJVPcG8C1xQ1AJ0vpAezJrB7JYyY= +github.com/onsi/gomega v1.10.1/go.mod h1:iN09h71vgCQne3DLsj+A5owkum+a2tYe+TOCB1ybHNo= +github.com/onsi/gomega v1.17.0/go.mod h1:HnhC7FXeEQY45zxNK3PPoIUhzk/80Xly9PcubAlGdZY= +github.com/onsi/gomega v1.18.1 h1:M1GfJqGRrBrrGGsbxzV5dqM2U2ApXefZCQpkukxYRLE= +github.com/onsi/gomega v1.18.1/go.mod h1:0q+aL8jAiMXy9hbwj2mr5GziHiwhAIQpFmmtT5hitRs= +github.com/opentracing/opentracing-go v1.2.1-0.20220228012449-10b1cf09e00b h1:FfH+VrHHk6Lxt9HdVS0PXzSXFyS2NbZKXv33FYPol0A= +github.com/opentracing/opentracing-go v1.2.1-0.20220228012449-10b1cf09e00b/go.mod h1:AC62GU6hc0BrNm+9RK9VSiwa/EUe1bkIeFORAMcHvJU= github.com/pbnjay/memory v0.0.0-20210728143218-7b4eea64cf58 h1:onHthvaw9LFnH4t2DcNVpwGmV9E1BkGknEliJkfwQj0= github.com/pbnjay/memory v0.0.0-20210728143218-7b4eea64cf58/go.mod h1:DXv8WO4yhMYhSNPKjeNKa5WY9YCIEBRbNzFFPJbWO6Y= github.com/pelletier/go-toml v1.9.5 h1:4yBQzkHv+7BHq2PQUZF3Mx0IYxG7LsP222s7Agd3ve8= @@ -246,25 +314,28 @@ github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0t github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= github.com/rs/xid v1.6.0 h1:fV591PaemRlL6JfRxGDEPl69wICngIQ3shQtzfy2gxU= github.com/rs/xid v1.6.0/go.mod h1:7XoLgs4eV+QndskICGsho+ADou8ySMSjJKDIan90Nz0= -github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= github.com/shirou/gopsutil/v3 v3.24.5 h1:i0t8kL+kQTvpAYToeuiVk3TgDeKOFioZO3Ztz/iZ9pI= github.com/shirou/gopsutil/v3 v3.24.5/go.mod h1:bsoOS1aStSs9ErQ1WWfxllSeS1K5D+U30r2NfcubMVk= github.com/shoenig/go-m1cpu v0.1.6 h1:nxdKQNcEB6vzgA2E2bvzKIYRuNj7XNJ4S/aRSwKzFtM= github.com/shoenig/go-m1cpu v0.1.6/go.mod h1:1JJMcUBvfNwpq05QDQVAnx3gUHr9IYF7GNg9SUEw2VQ= github.com/shoenig/test v0.6.4 h1:kVTaSd7WLz5WZ2IaoM0RSzRsUD+m8wRR+5qvntpn4LU= github.com/shoenig/test v0.6.4/go.mod h1:byHiCGXqrVaflBLAMq/srcZIHynQPQgeyvkvXnjqq0k= +github.com/silenceper/wechat/v2 v2.1.12 h1:hoBeuL7Mgafz/ox6rn6r02rffFxHZhu7E0SlDRa+m28= +github.com/silenceper/wechat/v2 v2.1.12/go.mod h1:7Iu3EhQYVtDUJAj+ZVRy8yom75ga7aDWv8RurLkVm0s= +github.com/sirupsen/logrus v1.9.0/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ= +github.com/sirupsen/logrus v1.9.4 h1:TsZE7l11zFCLZnZ+teH4Umoq5BhEIfIzfRDZ1Uzql2w= +github.com/sirupsen/logrus v1.9.4/go.mod h1:ftWc9WdOfJ0a92nsE2jF5u5ZwH8Bv2zdeOC42RjbV2g= github.com/spf13/afero v1.14.0 h1:9tH6MapGnn/j0eb0yIXiLjERO8RB6xIVZRDCX7PtqWA= github.com/spf13/afero v1.14.0/go.mod h1:acJQ8t0ohCGuMN3O+Pv0V0hgMxNYDlvdk+VTfyZmbYo= +github.com/spf13/cast v1.4.1/go.mod h1:Qx5cxh0v+4UWYiBimWS+eyWzqEqokIECu5etghLkUJE= github.com/spf13/cast v1.9.2 h1:SsGfm7M8QOFtEzumm7UZrZdLLquNdzFYfIbEXntcFbE= github.com/spf13/cast v1.9.2/go.mod h1:jNfB8QC9IA6ZuY2ZjDp0KtFO2LZZlg4S/7bzP6qqeHo= -github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU= -github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4= -github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY= -github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= +github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA= github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= @@ -280,71 +351,168 @@ github.com/tdewolff/test v1.0.11 h1:FdLbwQVHxqG16SlkGveC0JVyrJN62COWTRyUFzfbtBE= github.com/tdewolff/test v1.0.11/go.mod h1:XPuWBzvdUzhCuxWO1ojpXsyzsA5bFoS3tO/Q3kFuTG8= github.com/tetratelabs/wazero v1.9.0 h1:IcZ56OuxrtaEz8UYNRHBrUa9bYeX9oVY93KspZZBf/I= github.com/tetratelabs/wazero v1.9.0/go.mod h1:TSbcXCfFP0L2FGkRPxHphadXPjo1T6W+CseNNY7EkjM= +github.com/tidwall/gjson v1.14.1 h1:iymTbGkQBhveq21bEvAQ81I0LEBork8BFe1CUZXdyuo= +github.com/tidwall/gjson v1.14.1/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk= +github.com/tidwall/match v1.1.1 h1:+Ho715JplO36QYgwN9PGYNhgZvoUSc9X2c80KVTi+GA= +github.com/tidwall/match v1.1.1/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM= +github.com/tidwall/pretty v1.2.0 h1:RWIZEg2iJ8/g6fDDYzMpobmaoGh5OLl4AXtGUGPcqCs= +github.com/tidwall/pretty v1.2.0/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU= github.com/tklauser/go-sysconf v0.3.12 h1:0QaGUFOdQaIVdPgfITYzaTegZvdCjmYO52cSFAEVmqU= github.com/tklauser/go-sysconf v0.3.12/go.mod h1:Ho14jnntGE1fpdOqQEEaiKRpvIavV0hSfmBq8nJbHYI= github.com/tklauser/numcpus v0.6.1 h1:ng9scYS7az0Bk4OZLvrNXNSAO2Pxr1XXRAPyjhIx+Fk= github.com/tklauser/numcpus v0.6.1/go.mod h1:1XfjsgE2zo8GVw7POkMbHENHzVg3GzmoZ9fESEdAacY= github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI= github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08= +github.com/uber/jaeger-client-go v2.30.0+incompatible h1:D6wyKGCecFaSRUpo8lCVbaOOb6ThwMmTEbhRwtKR97o= +github.com/uber/jaeger-client-go v2.30.0+incompatible/go.mod h1:WVhlPFC8FDjOFMMWRy2pZqQJSXxYSwNYOkTr/Z6d3Kk= +github.com/uber/jaeger-lib v2.4.1+incompatible h1:td4jdvLcExb4cBISKIpHuGoVXh+dVKhn2Um6rjCsSsg= +github.com/uber/jaeger-lib v2.4.1+incompatible/go.mod h1:ComeNDZlWwrWnDv8aPp0Ba6+uUTzImX/AauajbLI56U= github.com/ugorji/go/codec v1.2.11 h1:BMaWp1Bb6fHwEtbplGBGJ498wD+LKlNSl25MjdZY4dU= github.com/ugorji/go/codec v1.2.11/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg= github.com/woodsbury/decimal128 v1.3.0 h1:8pffMNWIlC0O5vbyHWFZAt5yWvWcrHA+3ovIIjVWss0= github.com/woodsbury/decimal128 v1.3.0/go.mod h1:C5UTmyTjW3JftjUFzOVhC20BEQa2a4ZKOB5I6Zjb+ds= github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU= github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E= +github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= github.com/yuin/goldmark v1.7.13 h1:GPddIs617DnBLFFVJFgpo1aBfe/4xcvMc3SB5t/D0pA= github.com/yuin/goldmark v1.7.13/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg= github.com/yuin/goldmark-emoji v1.0.6 h1:QWfF2FYaXwL74tfGOW5izeiZepUDroDJfWubQI9HTHs= github.com/yuin/goldmark-emoji v1.0.6/go.mod h1:ukxJDKFpdFb5x0a5HqbdlcKtebh086iJpI31LTKmWuA= +github.com/yuin/gopher-lua v0.0.0-20220504180219-658193537a64 h1:5mLPGnFdSsevFRFc9q3yYbBkB6tsm4aCwwQV/j1JQAQ= +github.com/yuin/gopher-lua v0.0.0-20220504180219-658193537a64/go.mod h1:GBR0iDaNXjAgGg9zfCvksxSRnQx76gclCIb7kdAd1Pw= github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0= github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0= +go.uber.org/atomic v1.9.0 h1:ECmE8Bn/WFTYwEW/bpKD3M8VtR/zQVbavAoalC1PYyE= +go.uber.org/atomic v1.9.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc= go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= go.uber.org/multierr v1.10.0 h1:S0h4aNzvfcFsC3dRF1jLoaov7oRaKqRGC/pUEJ2yvPQ= go.uber.org/multierr v1.10.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= go.uber.org/zap v1.27.1 h1:08RqriUEv8+ArZRYSTXy1LeBScaMpVSTBhCeaZYfMYc= go.uber.org/zap v1.27.1/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E= -go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= golang.org/x/arch v0.0.0-20210923205945-b76863e36670/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8= golang.org/x/arch v0.3.0 h1:02VY4/ZcO/gBOH6PUaoiptASxtXU10jazRCP865E97k= golang.org/x/arch v0.3.0/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20190510104115-cbcb75029529/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= +golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= +golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= +golang.org/x/crypto v0.0.0-20220622213112-05595931fe9d/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= golang.org/x/crypto v0.46.0 h1:cKRW/pmt1pKAfetfu+RCEvjvZkA9RimPbh7bhFjGVBU= golang.org/x/crypto v0.46.0/go.mod h1:Evb/oLKmMraqjZ2iQTwDwvCtJkczlDuTmdJXoZVzqU0= +golang.org/x/exp v0.0.0-20180321215751-8460e604b9de/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= +golang.org/x/exp v0.0.0-20180807140117-3d87b88a115f/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= +golang.org/x/exp v0.0.0-20190125153040-c74c464bbbf2/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= +golang.org/x/exp v0.0.0-20190306152737-a1d7652674e8/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= +golang.org/x/exp v0.0.0-20191030013958-a1ab85dbe136/go.mod h1:JXzH8nQsPlswgeRAPE3MuO9GYsAcnJvJ4vnMwN/5qkY= golang.org/x/exp v0.0.0-20250819193227-8b4c13bb791b h1:DXr+pvt3nC887026GRP39Ej11UATqWDmWuS99x26cD0= golang.org/x/exp v0.0.0-20250819193227-8b4c13bb791b/go.mod h1:4QTo5u+SEIbbKW1RacMZq1YEfOBqeXa19JeshGi+zc4= +golang.org/x/image v0.0.0-20180708004352-c73c2afc3b81/go.mod h1:ux5Hcp/YLpHSI86hEcLt0YII63i6oz57MZXIpbrjZUs= +golang.org/x/image v0.0.0-20190227222117-0694c2d4d067/go.mod h1:kZ7UVZpmo3dzQBMxlp+ypCbDeSB+sBbTgSJuh5dn5js= +golang.org/x/image v0.0.0-20190802002840-cff245a6509b/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0= golang.org/x/image v0.30.0 h1:jD5RhkmVAnjqaCUXfbGBrn3lpxbknfN9w2UhHHU+5B4= golang.org/x/image v0.30.0/go.mod h1:SAEUTxCCMWSrJcCy/4HwavEsfZZJlYxeHLc6tTiAe/c= +golang.org/x/mobile v0.0.0-20190719004257-d2bd2a29d028/go.mod h1:E/iHnbuqvinMTCcRqshq8CkpyQDoeVncDDYHnLhea+o= +golang.org/x/mod v0.1.0/go.mod h1:0QHyrYULN0/3qlju5TqG8bIK38QM8yzMo5ekMj3DlcY= +golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.30.0 h1:fDEXFVZ/fmCKProc/yAXXUijritrDzahmwwefnjoPFk= golang.org/x/mod v0.30.0/go.mod h1:lAsf5O2EvJeSFMiBxXDki7sCgAxEUcZHXoXMKT4GJKc= +golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20200520004742-59133d7f0dd7/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A= +golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= +golang.org/x/net v0.0.0-20210428140749-89ef3d95e781/go.mod h1:OJAsFXCWl8Ukc7SiCT/9KSuxbyM7479/AVlXFRxuMCk= +golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= golang.org/x/net v0.47.0 h1:Mx+4dIFzqraBXUugkia1OOvlD6LemFo1ALMHjrXDOhY= golang.org/x/net v0.47.0/go.mod h1:/jNxtkgq5yWUGYkaZGqo27cfGZ1c5Nen03aYrrKpVRU= +golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4= golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= +golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190204203706-41f3e6584952/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190312061237-fead79001313/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190904154756-749cb33beabd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20191005200804-aed5e4c7ecf9/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20191120155948-bd437916bb0e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20191204072324-ce4227a45e2e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20201204225414-ed752295db88/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210112080510-489259a85091/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20211216021012-1d35b9e2eb4e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220704084225-05e143d24a9e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.11.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.39.0 h1:CvCKL8MeisomCi6qNZ+wbb0DN9E5AATixKsvNtMoMFk= golang.org/x/sys v0.39.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.32.0 h1:ZD01bjUt1FQ9WJ0ClOL5vxgxOI/sVCNgX1YtKwcY0mU= golang.org/x/text v0.32.0/go.mod h1:o/rUWzghvpD5TXrTIBuJU77MTaN0ljMWE47kxGJQ7jY= +golang.org/x/tools v0.0.0-20180525024113-a5b4c53f6e8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20190206041539-40960b6deb8e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20191012152004-8de300cfc20a/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.0.0-20201224043029-2b0845dc783e/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= golang.org/x/tools v0.39.0 h1:ik4ho21kwuQln40uelmciQPp9SipgNDdrafrYA4TmQQ= golang.org/x/tools v0.39.0/go.mod h1:JnefbkDPyD8UU2kI5fuf8ZX4/yUeh9W877ZeBONxUqQ= +golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +gonum.org/v1/gonum v0.0.0-20180816165407-929014505bf4/go.mod h1:Y+Yx5eoAFn32cQvJDxZx5Dpnq+c3wtXuadVZAcxbbBo= +gonum.org/v1/gonum v0.8.2/go.mod h1:oe/vMfY3deqTw+1EZJhuvEW2iwGF1bW9wwu7XCu0+v0= +gonum.org/v1/netlib v0.0.0-20190313105609-8cb42192e0e0/go.mod h1:wa6Ws7BG/ESfp6dHfk7C6KdzKA7wR7u/rKwOGE66zvw= +gonum.org/v1/plot v0.0.0-20190515093506-e2840ee46a6b/go.mod h1:Wt8AAjI+ypCyYX3nZBvf6cAIx93T+c/OS2HFAYskSZc= +google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8= +google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0= +google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM= +google.golang.org/protobuf v1.20.1-0.20200309200217-e05f789c0967/go.mod h1:A+miEFZTKqfCUM6K7xSMQL9OKL/b6hQv+e19PK+JZNE= +google.golang.org/protobuf v1.21.0/go.mod h1:47Nbq4nVaFHyn7ilMalzfO3qCViNmqZ2kzikPIcrTAo= +google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= +google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw= +google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc= google.golang.org/protobuf v1.36.8 h1:xHScyCOEuuwZEc6UtSOvPbAT4zRh0xcNRYekJwfqyMc= google.golang.org/protobuf v1.36.8/go.mod h1:fuxRtAxBytpl4zzqUh6/eyUujkJdNiuEkXntxiD/uRU= +gopkg.in/alexcesaro/quotedprintable.v3 v3.0.0-20150716171945-2caba252f4dc h1:2gGKlE2+asNV9m7xrywl36YYNnBG5ZQ0r/BOOxqPpmk= +gopkg.in/alexcesaro/quotedprintable.v3 v3.0.0-20150716171945-2caba252f4dc/go.mod h1:m7x9LTH6d71AHyAX77c9yqWCCa3UKHcVEj9y7hAtKDk= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= +gopkg.in/fsnotify.v1 v1.4.7/go.mod h1:Tz8NjZHkW78fSQdbUxIjBTcgA1z1m8ZHf0WmKUhAMys= +gopkg.in/gomail.v2 v2.0.0-20160411212932-81ebce5c23df h1:n7WqCuqOuCbNr617RXOY0AWRXxgwEyPp2z+p0+hgMuE= +gopkg.in/gomail.v2 v2.0.0-20160411212932-81ebce5c23df/go.mod h1:LRQQ+SO6ZHR7tOkpBDuZnXENFzX8qRjMDMyPD6BRkCw= +gopkg.in/h2non/gock.v1 v1.1.2 h1:jBbHXgGBK/AoPVfJh5x4r/WxIrElvbLel8TCZkkZJoY= +gopkg.in/h2non/gock.v1 v1.1.2/go.mod h1:n7UGz/ckNChHiK05rDoiC4MYSunEC/lyaUm2WWaDva0= gopkg.in/ini.v1 v1.67.0 h1:Dgnx+6+nfE+IfzjUEISNeydPJh9AXNNsWbGP9KzCsOA= gopkg.in/ini.v1 v1.67.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k= gopkg.in/natefinch/lumberjack.v2 v2.2.1 h1:bBRl1b0OH9s/DuPhuXpNl+VtCaJXFZ5/uEFST95x9zc= gopkg.in/natefinch/lumberjack.v2 v2.2.1/go.mod h1:YD8tP3GAjkrDg1eZH7EGmyESg/lsYskCTPBJVb9jqSc= +gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7 h1:uRGJdciOHaEIrze2W8Q3AKkepLTh2hOroT7a+7czfdQ= +gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw= +gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.2.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.3.0/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.0/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gorm.io/driver/mysql v1.6.0 h1:eNbLmNTpPpTOVZi8MMxCi2aaIm0ZpInbORNXDwyLGvg= diff --git a/internal/constant/constant.go b/internal/constant/constant.go index 77a6728..2ba75a1 100644 --- a/internal/constant/constant.go +++ b/internal/constant/constant.go @@ -34,6 +34,7 @@ const ( SectionSystem = "system" SectionScheduler = "scheduler" SectionSecurity = "security" + SectionNotify = "notify" // Site Settings Key 常量 KeyTitle = "title" @@ -54,6 +55,25 @@ const ( KeyQueueSize = "queue_size" KeyRateInterval = "rate_interval" + // Notify Settings Key 常量 + KeyNotifyChannels = "channels" + KeyNotifyEvents = "events" + KeyNotifyToken = "notify_token" + + // 事件绑定类型 + BindingTypeSystem = "system" + BindingTypeTask = "task" + + // 系统事件类型 + EventUserLogin = "user_login" + EventBruteForceLogin = "brute_force_login" + EventPasswordChanged = "password_changed" + + // 任务事件类型 + EventTaskSuccess = "task_success" + EventTaskFailed = "task_failed" + EventTaskTimeout = "task_timeout" + // WebSocket 消息类型 WSTypeHeartbeat = "heartbeat" WSTypeHeartbeatAck = "heartbeat_ack" diff --git a/internal/controllers/auth_controller.go b/internal/controllers/auth_controller.go index b7ce09a..5467ebb 100644 --- a/internal/controllers/auth_controller.go +++ b/internal/controllers/auth_controller.go @@ -1,6 +1,7 @@ package controllers import ( + "strconv" "sync" "time" @@ -54,6 +55,10 @@ func (ac *AuthController) Login(c *gin.Context) { attempt := val.(*loginAttempt) if attempt.Count >= 5 && time.Since(attempt.LastAttempt) < time.Minute { ac.loginLogService.Create(req.Username, ip, userAgent, "failed", "尝试次数过多,请一分钟后再试") + go services.NewNotificationService().TriggerEvent(constant.BindingTypeSystem, constant.EventBruteForceLogin, "", map[string]interface{}{ + "ip": ip, + "username": req.Username, + }) utils.TooManyRequests(c, "尝试次数过多,请一分钟后再试") return } @@ -102,6 +107,11 @@ func (ac *AuthController) Login(c *gin.Context) { // 记录登录成功日志 ac.loginLogService.Create(req.Username, ip, userAgent, "success", "登录成功") + go services.NewNotificationService().TriggerEvent(constant.BindingTypeSystem, constant.EventUserLogin, "", map[string]interface{}{ + "ip": ip, + "username": req.Username, + }) + utils.Success(c, gin.H{ "user": user.Username, }) diff --git a/internal/controllers/notification_controller.go b/internal/controllers/notification_controller.go new file mode 100644 index 0000000..f4e3c90 --- /dev/null +++ b/internal/controllers/notification_controller.go @@ -0,0 +1,168 @@ +package controllers + +import ( + "github.com/engigu/baihu-panel/internal/models" + "github.com/engigu/baihu-panel/internal/services" + "github.com/engigu/baihu-panel/internal/utils" + "github.com/gin-gonic/gin" +) + +type NotificationController struct { + notifyService *services.NotificationService +} + +func NewNotificationController() *NotificationController { + return &NotificationController{ + notifyService: services.NewNotificationService(), + } +} + +// GetChannelTypes 获取支持的渠道类型 +func (nc *NotificationController) GetChannelTypes(c *gin.Context) { + utils.Success(c, gin.H{ + "channel_types": services.SupportedChannelTypes, + "event_types": services.SupportedEvents, + }) +} + +// GetChannels 获取所有渠道 +func (nc *NotificationController) GetChannels(c *gin.Context) { + channels := nc.notifyService.GetChannels() + utils.Success(c, channels) +} + +// SaveChannel 保存/更新渠道 +func (nc *NotificationController) SaveChannel(c *gin.Context) { + var req services.NotifyChannel + if err := c.ShouldBindJSON(&req); err != nil { + utils.BadRequest(c, "参数错误") + return + } + + if req.Name == "" || req.Type == "" { + utils.BadRequest(c, "渠道名称和类型不能为空") + return + } + + if err := nc.notifyService.SaveChannel(req); err != nil { + utils.ServerError(c, err.Error()) + return + } + + utils.SuccessMsg(c, "保存成功") +} + +// DeleteChannel 删除渠道 +func (nc *NotificationController) DeleteChannel(c *gin.Context) { + id := c.Param("id") + if id == "" { + utils.BadRequest(c, "缺少渠道ID") + return + } + + if err := nc.notifyService.DeleteChannel(id); err != nil { + utils.ServerError(c, err.Error()) + return + } + + utils.SuccessMsg(c, "删除成功") +} + +// TestChannel 测试渠道 +func (nc *NotificationController) TestChannel(c *gin.Context) { + var req services.NotifyChannel + if err := c.ShouldBindJSON(&req); err != nil { + utils.BadRequest(c, "参数错误") + return + } + + result := nc.notifyService.SendToChannel(req, &services.NotifyMessage{ + Title: "🔔 白虎面板测试通知", + Text: "如果你看到这条消息,说明通知渠道配置正确!", + }) + + utils.Success(c, result) +} + + +// GetBindings 获取事件绑定列表 +func (nc *NotificationController) GetBindings(c *gin.Context) { + bindings := nc.notifyService.GetBindings() + utils.Success(c, bindings) +} + +// SaveBinding 保存事件绑定 +func (nc *NotificationController) SaveBinding(c *gin.Context) { + var req struct { + ID string `json:"id"` + Type string `json:"type"` + Event string `json:"event"` + WayID string `json:"way_id"` + DataID string `json:"data_id"` + } + if err := c.ShouldBindJSON(&req); err != nil { + utils.BadRequest(c, "参数错误") + return + } + + if req.Type == "" || req.Event == "" || req.WayID == "" { + utils.BadRequest(c, "类型、事件和渠道ID不能为空") + return + } + + binding := &models.NotifyBinding{ + ID: req.ID, + Type: req.Type, + Event: req.Event, + WayID: req.WayID, + DataID: req.DataID, + } + + if err := nc.notifyService.SaveBinding(binding); err != nil { + utils.ServerError(c, err.Error()) + return + } + + utils.Success(c, binding) +} + +// DeleteBinding 删除事件绑定 +func (nc *NotificationController) DeleteBinding(c *gin.Context) { + id := c.Param("id") + if id == "" { + utils.BadRequest(c, "缺少绑定ID") + return + } + + if err := nc.notifyService.DeleteBinding(id); err != nil { + utils.ServerError(c, err.Error()) + return + } + + utils.SuccessMsg(c, "删除成功") +} + +// SendNotification API 发送通知(供脚本调用) +func (nc *NotificationController) SendNotification(c *gin.Context) { + var req struct { + ChannelID string `json:"channel_id"` + Title string `json:"title"` + Text string `json:"text"` + } + if err := c.ShouldBindJSON(&req); err != nil { + utils.BadRequest(c, "参数错误") + return + } + + if req.ChannelID == "" || req.Title == "" { + utils.BadRequest(c, "channel_id 和 title 不能为空") + return + } + + result := nc.notifyService.SendByChannelID(req.ChannelID, &services.NotifyMessage{ + Title: req.Title, + Text: req.Text, + }) + + utils.Success(c, result) +} diff --git a/internal/controllers/settings_controller.go b/internal/controllers/settings_controller.go index f1093b7..3027e00 100644 --- a/internal/controllers/settings_controller.go +++ b/internal/controllers/settings_controller.go @@ -76,6 +76,10 @@ func (sc *SettingsController) ChangePassword(c *gin.Context) { return } + go services.NewNotificationService().TriggerEvent(constant.BindingTypeSystem, constant.EventPasswordChanged, "", map[string]interface{}{ + "username": user.Username, + }) + utils.SuccessMsg(c, "密码修改成功") } @@ -373,3 +377,39 @@ func (sc *SettingsController) RestoreBackup(c *gin.Context) { utils.SuccessMsg(c, "恢复成功") } + +// GetSetting 获取单个设置值 +func (sc *SettingsController) GetSetting(c *gin.Context) { + section := c.Param("section") + key := c.Param("key") + + if section == "" || key == "" { + utils.BadRequest(c, "参数错误") + return + } + + value := sc.settingsService.Get(section, key) + utils.Success(c, value) +} + +// GenerateSettingToken 为指定设置生成随机token +func (sc *SettingsController) GenerateSettingToken(c *gin.Context) { + section := c.Param("section") + key := c.Param("key") + + if section == "" || key == "" { + utils.BadRequest(c, "参数错误") + return + } + + // 生成32位随机token + token := strings.ToLower(utils.RandomString(32)) + + // 保存到数据库 + if err := sc.settingsService.Set(section, key, token); err != nil { + utils.ServerError(c, "保存失败") + return + } + + utils.Success(c, token) +} diff --git a/internal/database/migrate.go b/internal/database/migrate.go index faa7b72..1a1a06b 100644 --- a/internal/database/migrate.go +++ b/internal/database/migrate.go @@ -24,6 +24,8 @@ func Migrate() error { &models.Agent{}, &models.AgentToken{}, &models.Language{}, + &models.NotifyWay{}, + &models.NotifyBinding{}, ) } diff --git a/internal/middleware/notify_token.go b/internal/middleware/notify_token.go new file mode 100644 index 0000000..daf06b3 --- /dev/null +++ b/internal/middleware/notify_token.go @@ -0,0 +1,38 @@ +package middleware + +import ( + "github.com/engigu/baihu-panel/internal/constant" + "github.com/engigu/baihu-panel/internal/services" + "github.com/engigu/baihu-panel/internal/utils" + "github.com/gin-gonic/gin" +) + +// NotifyTokenAuth 通知 Token 认证中间件 +func NotifyTokenAuth() gin.HandlerFunc { + return func(c *gin.Context) { + token := c.GetHeader("notify-token") + if token == "" { + utils.Unauthorized(c, "缺少通知 Token") + c.Abort() + return + } + + // 从 settings 表读取配置的通知 Token + settingsService := services.NewSettingsService() + savedToken := settingsService.Get(constant.SectionNotify, constant.KeyNotifyToken) + + if savedToken == "" { + utils.Unauthorized(c, "通知 Token 未配置") + c.Abort() + return + } + + if token != savedToken { + utils.Unauthorized(c, "通知 Token 无效") + c.Abort() + return + } + + c.Next() + } +} diff --git a/internal/models/notify_binding.go b/internal/models/notify_binding.go new file mode 100644 index 0000000..b4b6e99 --- /dev/null +++ b/internal/models/notify_binding.go @@ -0,0 +1,23 @@ +package models + +import ( + "github.com/engigu/baihu-panel/internal/constant" + + "gorm.io/gorm" +) + +// NotifyBinding 事件绑定表 +type NotifyBinding struct { + ID string `json:"id" gorm:"primaryKey;size:20"` + Type string `json:"type" gorm:"size:20;not null;index"` // system 或 task + Event string `json:"event" gorm:"size:50;not null;index"` // 事件类型 + WayID string `json:"way_id" gorm:"size:20;not null;index"` // 通知渠道ID + DataID string `json:"data_id" gorm:"size:20;index"` // 关联ID,系统事件为空,任务事件为任务ID + CreatedAt LocalTime `json:"created_at"` + UpdatedAt LocalTime `json:"updated_at"` + DeletedAt gorm.DeletedAt `json:"-" gorm:"index"` +} + +func (NotifyBinding) TableName() string { + return constant.TablePrefix + "notify_bindings" +} diff --git a/internal/models/notify_way.go b/internal/models/notify_way.go new file mode 100644 index 0000000..63a0f5a --- /dev/null +++ b/internal/models/notify_way.go @@ -0,0 +1,23 @@ +package models + +import ( + "github.com/engigu/baihu-panel/internal/constant" + + "gorm.io/gorm" +) + +// NotifyWay 消息推送渠道 +type NotifyWay struct { + ID string `json:"id" gorm:"primaryKey;size:20"` + Name string `json:"name" gorm:"size:100;not null"` + Type string `json:"type" gorm:"size:50;not null;index"` + Config string `json:"config" gorm:"type:text"` + Enabled bool `json:"enabled" gorm:"default:true;index"` + CreatedAt LocalTime `json:"created_at"` + UpdatedAt LocalTime `json:"updated_at"` + DeletedAt gorm.DeletedAt `json:"-" gorm:"index"` +} + +func (NotifyWay) TableName() string { + return constant.TablePrefix + "notify_ways" +} diff --git a/internal/router/register.go b/internal/router/register.go index edf617f..e6827bb 100644 --- a/internal/router/register.go +++ b/internal/router/register.go @@ -30,7 +30,7 @@ func RegisterControllers() *Controllers { // 清理 task 运行状态的任务可以直接由 executorService 承担或在此处通过 Database 直接清理 // 简单期间,我们使用一个新方法 tasks.CleanupRunningTasks() 或者让 executorService 启动时清理 - executorService = tasks.NewExecutorService(taskService, taskLogService, agentWSManager, settingsService, envService) + executorService = tasks.NewExecutorService(taskService, taskLogService, agentWSManager, settingsService, envService, services.NewNotificationService()) // 启动时清理残留的运行状态 _ = executorService.CleanupRunningTasks() @@ -39,20 +39,21 @@ func RegisterControllers() *Controllers { // 初始化并返回控制器 return &Controllers{ - Task: controllers.NewTaskController(taskService, executorService), - Auth: controllers.NewAuthController(userService, settingsService, loginLogService), - Env: controllers.NewEnvController(envService), - Script: controllers.NewScriptController(scriptService), - Executor: controllers.NewExecutorController(executorService), - File: controllers.NewFileController(constant.ScriptsWorkDir), - Dashboard: controllers.NewDashboardController(executorService), - Log: controllers.NewLogController(), - LogWS: controllers.NewLogWSController(), - Terminal: controllers.NewTerminalController(envService), - Settings: controllers.NewSettingsController(userService, loginLogService, executorService), - Dependency: controllers.NewDependencyController(), - Agent: controllers.NewAgentController(settingsService), - Mise: controllers.NewMiseController(services.NewMiseService()), + Task: controllers.NewTaskController(taskService, executorService), + Auth: controllers.NewAuthController(userService, settingsService, loginLogService), + Env: controllers.NewEnvController(envService), + Script: controllers.NewScriptController(scriptService), + Executor: controllers.NewExecutorController(executorService), + File: controllers.NewFileController(constant.ScriptsWorkDir), + Dashboard: controllers.NewDashboardController(executorService), + Log: controllers.NewLogController(), + LogWS: controllers.NewLogWSController(), + Terminal: controllers.NewTerminalController(envService), + Settings: controllers.NewSettingsController(userService, loginLogService, executorService), + Dependency: controllers.NewDependencyController(), + Agent: controllers.NewAgentController(settingsService), + Mise: controllers.NewMiseController(services.NewMiseService()), + Notification: controllers.NewNotificationController(), } } diff --git a/internal/router/router.go b/internal/router/router.go index e681f59..bd04390 100644 --- a/internal/router/router.go +++ b/internal/router/router.go @@ -26,8 +26,9 @@ type Controllers struct { Terminal *controllers.TerminalController Settings *controllers.SettingsController Dependency *controllers.DependencyController - Agent *controllers.AgentController - Mise *controllers.MiseController + Agent *controllers.AgentController + Mise *controllers.MiseController + Notification *controllers.NotificationController } func mustSubFS(fsys fs.FS, dir string) fs.FS { @@ -199,6 +200,9 @@ func Setup(c *Controllers) *gin.Engine { settings.GET("/backup/status", c.Settings.GetBackupStatus) settings.GET("/backup/download", c.Settings.DownloadBackup) settings.POST("/restore", c.Settings.RestoreBackup) + // 通用设置接口 + settings.GET("/:section/:key", c.Settings.GetSetting) + settings.POST("/:section/:key/generate", c.Settings.GenerateSettingToken) } // Dependency routes (依赖管理) @@ -246,6 +250,26 @@ func Setup(c *Controllers) *gin.Engine { { agentAPIv1.GET("/download", c.Agent.Download) } + + // 通知推送模块 + notify := authorized.Group("/notify") + { + notify.GET("/types", c.Notification.GetChannelTypes) + notify.GET("/channels", c.Notification.GetChannels) + notify.POST("/channels", c.Notification.SaveChannel) + notify.DELETE("/channels/:id", c.Notification.DeleteChannel) + notify.POST("/channels/test", c.Notification.TestChannel) + notify.GET("/bindings", c.Notification.GetBindings) + notify.POST("/bindings", c.Notification.SaveBinding) + notify.DELETE("/bindings/:id", c.Notification.DeleteBinding) + } + } + + // 通知发送 API(使用通知 Token 认证,供脚本调用) + notifyAPI := api.Group("/notify") + notifyAPI.Use(middleware.NotifyTokenAuth()) + { + notifyAPI.POST("/send", c.Notification.SendNotification) } } diff --git a/internal/sdk/message/bark.go b/internal/sdk/message/bark.go new file mode 100644 index 0000000..221ae2e --- /dev/null +++ b/internal/sdk/message/bark.go @@ -0,0 +1,140 @@ +package message + +import ( + "bytes" + "crypto/aes" + "crypto/cipher" + "encoding/base64" + "encoding/json" + "fmt" + "io" + "net/http" + "strings" +) + +type barkResponse struct { + Code int `json:"code"` + Message string `json:"message"` +} + +type Bark struct { + PushKey string + Archive string + Group string + Sound string + Icon string + Level string + URL string + Key string + IV string +} + +func (b *Bark) Request(title, content string) ([]byte, error) { + data := map[string]interface{}{ + "title": title, + "body": content, + } + if b.Archive != "" { + data["isArchive"] = b.Archive + } + if b.Group != "" { + data["group"] = b.Group + } + if b.Sound != "" { + data["sound"] = b.Sound + } + if b.Icon != "" { + data["icon"] = b.Icon + } + if b.Level != "" { + data["level"] = b.Level + } + if b.URL != "" { + data["url"] = b.URL + } + + var postData interface{} + url := b.getURL() + + if b.Key != "" && b.IV != "" { + // Use encryption + jsonData, err := json.Marshal(data) + if err != nil { + return nil, err + } + ciphertext, err := b.encryptPayload(string(jsonData)) + if err != nil { + return nil, fmt.Errorf("encryption failed: %v", err) + } + postData = map[string]interface{}{ + "ciphertext": ciphertext, + "device_key": b.PushKey, + "sound": b.Sound, + } + // When using encryption, use the push endpoint if PushKey is just a key + if !strings.HasPrefix(b.PushKey, "http") { + url = "https://api.day.app/push" + } + } else { + // Normal request + postData = data + } + + jsonData, err := json.Marshal(postData) + if err != nil { + return nil, err + } + + resp, err := http.Post(url, "application/json;charset=utf-8", bytes.NewBuffer(jsonData)) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, err + } + + var r barkResponse + err = json.Unmarshal(body, &r) + if err != nil { + return body, err + } + + if r.Code != 200 { + return body, fmt.Errorf("bark response error: %s", string(body)) + } + return body, nil +} + +func (b *Bark) getURL() string { + pushKey := b.PushKey + if strings.HasPrefix(pushKey, "http") { + return pushKey + } + return fmt.Sprintf("https://api.day.app/%s", pushKey) +} + +func (b *Bark) encryptPayload(payload string) (string, error) { + key := []byte(b.Key) + iv := []byte(b.IV) + + block, err := aes.NewCipher(key) + if err != nil { + return "", err + } + + paddedPayload := b.pkcs7Pad([]byte(payload), aes.BlockSize) + mode := cipher.NewCBCEncrypter(block, iv) + ciphertext := make([]byte, len(paddedPayload)) + mode.CryptBlocks(ciphertext, paddedPayload) + + return base64.StdEncoding.EncodeToString(ciphertext), nil +} + +func (b *Bark) pkcs7Pad(data []byte, blockSize int) []byte { + padding := blockSize - len(data)%blockSize + padtext := bytes.Repeat([]byte{byte(padding)}, padding) + return append(data, padtext...) +} diff --git a/internal/sdk/message/custom.go b/internal/sdk/message/custom.go new file mode 100644 index 0000000..19f9dc8 --- /dev/null +++ b/internal/sdk/message/custom.go @@ -0,0 +1,36 @@ +package message + +import ( + "bytes" + "io" + "net/http" + "time" +) + +type CustomWebhook struct { + Webhook string + Body string +} + +var Client = &http.Client{ + Timeout: 5 * time.Second, +} + +func (cw *CustomWebhook) Request(url string, msg string) ([]byte, error) { + resp, err := Client.Post(url, "application/json", bytes.NewBuffer([]byte(msg))) + if err != nil { + return nil, err + } + defer func(Body io.ReadCloser) { + err := Body.Close() + if err != nil { + + } + }(resp.Body) + + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, err + } + return body, err +} diff --git a/internal/sdk/message/dtalk.go b/internal/sdk/message/dtalk.go new file mode 100644 index 0000000..4eef9cb --- /dev/null +++ b/internal/sdk/message/dtalk.go @@ -0,0 +1,133 @@ +package message + +import ( + "bytes" + "crypto/hmac" + "crypto/sha256" + "encoding/base64" + "encoding/json" + "fmt" + "io" + "net/http" + "time" +) + +type response struct { + Code int `json:"errcode"` + Msg string `json:"errmsg"` +} + +type Dtalk struct { + AccessToken string + Secret string +} + +func (t *Dtalk) Request(msg interface{}) ([]byte, error) { + b, err := json.Marshal(msg) + if err != nil { + return nil, err + } + resp, err := http.Post(t.getURL(), "application/json", bytes.NewBuffer(b)) + if err != nil { + return nil, err + } + + defer func(Body io.ReadCloser) { + err := Body.Close() + if err != nil { + + } + }(resp.Body) + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, err + } + var r response + err = json.Unmarshal(body, &r) + if err != nil { + return body, err + } + if r.Code != 0 { + return body, fmt.Errorf("response error: %s", string(body)) + } + return body, err +} + +// SendMessageText Function to send message +func (t *Dtalk) SendMessageText(text string, at ...string) ([]byte, error) { + msg := map[string]interface{}{ + "msgtype": "text", + "text": map[string]string{ + "content": text, + }, + } + + // 添加@功能 + if len(at) > 0 { + atMobiles := []string{} + isAtAll := false + + for _, mobile := range at { + if mobile == "all" || mobile == "@all" { + isAtAll = true + } else { + atMobiles = append(atMobiles, mobile) + } + } + + msg["at"] = map[string]interface{}{ + "atMobiles": atMobiles, + "isAtAll": isAtAll, + } + } + + resp, err := t.Request(msg) + return resp, err +} + +func (t *Dtalk) SendMessageMarkdown(title, text string, at ...string) ([]byte, error) { + msg := map[string]interface{}{ + "msgtype": "markdown", + "markdown": map[string]string{ + "title": title, + "text": text, + }, + } + + // 添加@功能 + if len(at) > 0 { + atMobiles := []string{} + isAtAll := false + + for _, mobile := range at { + if mobile == "all" || mobile == "@all" { + isAtAll = true + } else { + atMobiles = append(atMobiles, mobile) + } + } + + msg["at"] = map[string]interface{}{ + "atMobiles": atMobiles, + "isAtAll": isAtAll, + } + } + + resp, err := t.Request(msg) + return resp, err +} + +func (t *Dtalk) hmacSha256(stringToSign string, secret string) string { + h := hmac.New(sha256.New, []byte(secret)) + h.Write([]byte(stringToSign)) + return base64.StdEncoding.EncodeToString(h.Sum(nil)) +} + +func (t *Dtalk) getURL() string { + wh := "https://oapi.dingtalk.com/robot/send?access_token=" + t.AccessToken + timestamp := time.Now().UnixNano() / 1e6 + stringToSign := fmt.Sprintf("%d\n%s", timestamp, t.Secret) + sign := t.hmacSha256(stringToSign, t.Secret) + url := fmt.Sprintf("%s×tamp=%d&sign=%s", wh, timestamp, sign) + return url +} diff --git a/internal/sdk/message/email.go b/internal/sdk/message/email.go new file mode 100644 index 0000000..f00b79e --- /dev/null +++ b/internal/sdk/message/email.go @@ -0,0 +1,46 @@ +package message + +import ( + "fmt" + + "gopkg.in/gomail.v2" +) + +type EmailMessage struct { + Server string + Port int + Account string + Passwd string + FromName string + GM *gomail.Dialer +} + +func (e *EmailMessage) Init(host string, port int, account string, passwd string, fromName string) { + e.Server = host + e.Port = port + e.Account = account + e.Passwd = passwd + e.FromName = fromName + e.GM = gomail.NewDialer(host, port, account, passwd) +} + +func (e *EmailMessage) SendTextMessage(toEmail string, title string, content string) string { + m := gomail.NewMessage() + if e.FromName != "" { + m.SetAddressHeader("From", e.Account, e.FromName) + } else { + m.SetHeader("From", e.Account) + } + m.SetHeader("To", toEmail) + m.SetHeader("Subject", title) + m.SetBody("text/html", content) + + if err := e.GM.DialAndSend(m); err != nil { + return fmt.Sprintf("邮件发送失败: %s", err) + } + return "" +} + +func (e *EmailMessage) SendHtmlMessage(toEmail string, title string, content string) string { + return e.SendTextMessage(toEmail, title, content) +} diff --git a/internal/sdk/message/feishu.go b/internal/sdk/message/feishu.go new file mode 100644 index 0000000..3f183ff --- /dev/null +++ b/internal/sdk/message/feishu.go @@ -0,0 +1,133 @@ +package message + +import ( + "bytes" + "crypto/hmac" + "crypto/sha256" + "encoding/base64" + "encoding/json" + "fmt" + "io" + "net/http" + "strconv" + "time" +) + +type feishuResponse struct { + Code int `json:"code"` + Msg string `json:"msg"` +} + +type Feishu struct { + AccessToken string + Secret string +} + +// genSign 生成飞书签名 +func (f *Feishu) genSign(timestamp int64) string { + if f.Secret == "" { + return "" + } + stringToSign := fmt.Sprintf("%v\n%s", timestamp, f.Secret) + h := hmac.New(sha256.New, []byte(stringToSign)) + signature := base64.StdEncoding.EncodeToString(h.Sum(nil)) + return signature +} + +// SendMessageText 发送文本消息 +func (f *Feishu) SendMessageText(content string, atMobiles ...string) ([]byte, error) { + timestamp := time.Now().Unix() + sign := f.genSign(timestamp) + + msg := map[string]interface{}{ + "timestamp": strconv.FormatInt(timestamp, 10), + "sign": sign, + "msg_type": "text", + "content": map[string]interface{}{ + "text": content, + }, + } + + return f.send(msg) +} + +// SendMessageMarkdown 发送 Markdown 消息 +func (f *Feishu) SendMessageMarkdown(title, content string, atMobiles ...string) ([]byte, error) { + timestamp := time.Now().Unix() + sign := f.genSign(timestamp) + + // 处理 @ 人员 + atContent := "" + if len(atMobiles) > 0 { + for _, mobile := range atMobiles { + if mobile == "all" { + atContent += "所有人" + } else { + atContent += fmt.Sprintf("", mobile) + } + } + content = atContent + "\n" + content + } + + msg := map[string]interface{}{ + "timestamp": strconv.FormatInt(timestamp, 10), + "sign": sign, + "msg_type": "interactive", + "card": map[string]interface{}{ + "header": map[string]interface{}{ + "title": map[string]interface{}{ + "tag": "plain_text", + "content": title, + }, + }, + "elements": []map[string]interface{}{ + { + "tag": "markdown", + "content": content, + }, + }, + }, + } + + return f.send(msg) +} + +// send 发送请求 +func (f *Feishu) send(msg map[string]interface{}) ([]byte, error) { + url := fmt.Sprintf("https://open.feishu.cn/open-apis/bot/v2/hook/%s", f.AccessToken) + + jsonData, err := json.Marshal(msg) + if err != nil { + return nil, fmt.Errorf("JSON序列化失败: %v", err) + } + + req, err := http.NewRequest("POST", url, bytes.NewBuffer(jsonData)) + if err != nil { + return nil, fmt.Errorf("创建请求失败: %v", err) + } + + req.Header.Set("Content-Type", "application/json") + + client := &http.Client{Timeout: 10 * time.Second} + resp, err := client.Do(req) + if err != nil { + return nil, fmt.Errorf("发送请求失败: %v", err) + } + defer resp.Body.Close() + + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("读取响应失败: %v", err) + } + + var result feishuResponse + if err := json.Unmarshal(body, &result); err != nil { + return body, fmt.Errorf("解析响应失败: %v", err) + } + + if result.Code != 0 { + return body, fmt.Errorf("飞书返回错误: %s", result.Msg) + } + + return body, nil +} diff --git a/internal/sdk/message/gotify.go b/internal/sdk/message/gotify.go new file mode 100644 index 0000000..6befe76 --- /dev/null +++ b/internal/sdk/message/gotify.go @@ -0,0 +1,67 @@ +package message + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" +) + +type gotifyResponse struct { + Id int `json:"id"` + Message string `json:"message"` + ErrorCode int `json:"errorCode"` +} + +type Gotify struct { + Url string + Token string + Priority int +} + +func (g *Gotify) Request(title, content string) ([]byte, error) { + // Construct the URL with token + u, err := url.Parse(fmt.Sprintf("%s/message", g.Url)) + if err != nil { + return nil, err + } + q := u.Query() + q.Set("token", g.Token) + u.RawQuery = q.Encode() + + data := map[string]interface{}{ + "title": title, + "message": content, + "priority": g.Priority, + } + + jsonData, err := json.Marshal(data) + if err != nil { + return nil, err + } + + resp, err := http.Post(u.String(), "application/json", bytes.NewBuffer(jsonData)) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, err + } + + var r gotifyResponse + err = json.Unmarshal(body, &r) + if err != nil { + return body, err + } + + if r.Id == 0 { + return body, fmt.Errorf("gotify response error: %s", string(body)) + } + + return body, nil +} diff --git a/internal/sdk/message/ntfy.go b/internal/sdk/message/ntfy.go new file mode 100644 index 0000000..4d229ad --- /dev/null +++ b/internal/sdk/message/ntfy.go @@ -0,0 +1,76 @@ +package message + +import ( + "bytes" + "encoding/base64" + "fmt" + "io" + "net/http" +) + +type Ntfy struct { + Url string + Topic string + Priority string + Icon string + Token string + Username string + Password string + Actions string +} + +func encodeRFC2047(text string) string { + encoded := base64.StdEncoding.EncodeToString([]byte(text)) + return fmt.Sprintf("=?utf-8?B?%s?=", encoded) +} + +func (n *Ntfy) Request(title, content string) ([]byte, error) { + if n.Url == "" { + n.Url = "https://ntfy.sh" + } + url := fmt.Sprintf("%s/%s", n.Url, n.Topic) + + req, err := http.NewRequest("POST", url, bytes.NewBufferString(content)) + if err != nil { + return nil, err + } + + req.Header.Set("Title", encodeRFC2047(title)) + priority := n.Priority + if priority == "" { + priority = "3" + } + req.Header.Set("Priority", priority) + if n.Icon != "" { + req.Header.Set("Icon", n.Icon) + } + if n.Actions != "" { + req.Header.Set("Actions", encodeRFC2047(n.Actions)) + } + + if n.Token != "" { + req.Header.Set("Authorization", "Bearer "+n.Token) + } else if n.Username != "" && n.Password != "" { + authStr := n.Username + ":" + n.Password + encodedAuth := base64.StdEncoding.EncodeToString([]byte(authStr)) + req.Header.Set("Authorization", "Basic "+encodedAuth) + } + + client := &http.Client{} + resp, err := client.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, err + } + + if resp.StatusCode != http.StatusOK { + return body, fmt.Errorf("ntfy response error: %s", string(body)) + } + + return body, nil +} diff --git a/internal/sdk/message/pushme.go b/internal/sdk/message/pushme.go new file mode 100644 index 0000000..680742f --- /dev/null +++ b/internal/sdk/message/pushme.go @@ -0,0 +1,51 @@ +package message + +import ( + "fmt" + "io" + "net/http" + "net/url" + "strings" +) + +type PushMe struct { + PushKey string + URL string + Date string + Type string +} + +func (p *PushMe) Request(title, content string) (string, error) { + apiURL := p.URL + if apiURL == "" { + apiURL = "https://push.i-i.me/" + } + + data := url.Values{} + data.Set("push_key", p.PushKey) + data.Set("title", title) + data.Set("content", content) + if p.Date != "" { + data.Set("date", p.Date) + } + if p.Type != "" { + data.Set("type", p.Type) + } + + resp, err := http.Post(apiURL, "application/x-www-form-urlencoded", strings.NewReader(data.Encode())) + if err != nil { + return "", err + } + defer resp.Body.Close() + + body, err := io.ReadAll(resp.Body) + if err != nil { + return "", err + } + + if resp.StatusCode == 200 && string(body) == "success" { + return string(body), nil + } + + return string(body), fmt.Errorf("PushMe response error: %s", string(body)) +} diff --git a/internal/sdk/message/qyweixin.go b/internal/sdk/message/qyweixin.go new file mode 100644 index 0000000..8457954 --- /dev/null +++ b/internal/sdk/message/qyweixin.go @@ -0,0 +1,111 @@ +package message + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "net/http" +) + +type qywxResponse struct { + Code int `json:"errcode"` + Msg string `json:"errmsg"` +} + +type QyWeiXin struct { + AccessToken string +} + +func (t *QyWeiXin) Request(msg interface{}) ([]byte, error) { + b, err := json.Marshal(msg) + if err != nil { + return nil, err + } + resp, err := http.Post(t.getURL(), "application/json", bytes.NewBuffer(b)) + if err != nil { + return nil, err + } + + defer func(Body io.ReadCloser) { + err := Body.Close() + if err != nil { + + } + }(resp.Body) + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, err + } + var r qywxResponse + err = json.Unmarshal(body, &r) + if err != nil { + return body, err + } + if r.Code != 0 { + return body, fmt.Errorf("response error: %s", string(body)) + } + return body, err +} + +// SendMessageText Function to send message +func (t *QyWeiXin) SendMessageText(text string, at ...string) ([]byte, error) { + msg := map[string]interface{}{ + "msgtype": "text", + "text": map[string]interface{}{ + "content": text, + }, + } + + // 添加@功能 + // 企业微信支持两种@方式: + // 1. mentioned_list: userid列表或"@all" + // 2. mentioned_mobile_list: 手机号列表 + if len(at) > 0 { + mentionedList := []string{} + mentionedMobileList := []string{} + + for _, item := range at { + if item == "@all" || item == "all" { + mentionedList = append(mentionedList, "@all") + } else if len(item) == 11 && item[0] == '1' { + // 判断是否为手机号(简单判断:11位且以1开头) + mentionedMobileList = append(mentionedMobileList, item) + } else { + // 否则当作userid处理 + mentionedList = append(mentionedList, item) + } + } + + textContent := msg["text"].(map[string]interface{}) + if len(mentionedList) > 0 { + textContent["mentioned_list"] = mentionedList + } + if len(mentionedMobileList) > 0 { + textContent["mentioned_mobile_list"] = mentionedMobileList + } + } + + resp, err := t.Request(msg) + return resp, err +} + +func (t *QyWeiXin) SendMessageMarkdown(title, text string, at ...string) ([]byte, error) { + msg := map[string]interface{}{ + "msgtype": "markdown", + "markdown": map[string]interface{}{ + "content": text, + }, + } + + // 企业微信Markdown消息不支持@功能,但可以在内容中手动添加 + // 如果需要@功能,建议使用text类型 + + resp, err := t.Request(msg) + return resp, err +} + +func (t *QyWeiXin) getURL() string { + url := "https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=" + t.AccessToken + return url +} diff --git a/internal/sdk/message/telegram.go b/internal/sdk/message/telegram.go new file mode 100644 index 0000000..699cd59 --- /dev/null +++ b/internal/sdk/message/telegram.go @@ -0,0 +1,181 @@ +package message + +import ( + "bytes" + "encoding/json" + "errors" + "fmt" + "io" + "net" + "net/http" + "net/url" + "strings" + "time" + + "golang.org/x/net/proxy" +) + +type telegramResponse struct { + Ok bool `json:"ok"` + Description string `json:"description"` +} + +type Telegram struct { + BotToken string + ChatID string + ApiHost string // 可选的自定义API地址(优先级最高) + ProxyURL string // 可选的代理地址,支持 http://、https://、socks5:// 格式 +} + +func (t *Telegram) Request(params map[string]interface{}) ([]byte, error) { + apiURL := t.getAPIURL() + + // 构建请求体 + data := url.Values{} + for key, value := range params { + data.Set(key, fmt.Sprintf("%v", value)) + } + + // 创建 HTTP 客户端 + client := t.getHTTPClient() + + resp, err := client.Post(apiURL, "application/x-www-form-urlencoded", bytes.NewBufferString(data.Encode())) + if err != nil { + return nil, err + } + + defer func(Body io.ReadCloser) { + err := Body.Close() + if err != nil { + // 忽略关闭错误 + } + }(resp.Body) + + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, err + } + + var r telegramResponse + err = json.Unmarshal(body, &r) + if err != nil { + return body, err + } + + if !r.Ok { + return body, fmt.Errorf("telegram api error: %s", r.Description) + } + + return body, nil +} + +// SendMessageText 发送文本消息 +func (t *Telegram) SendMessageText(text string) ([]byte, error) { + params := map[string]interface{}{ + "chat_id": t.ChatID, + "text": text, + "disable_web_page_preview": "true", + } + + return t.Request(params) +} + +// SendMessageMarkdown 发送Markdown格式消息 +func (t *Telegram) SendMessageMarkdown(text string) ([]byte, error) { + params := map[string]interface{}{ + "chat_id": t.ChatID, + "text": text, + "parse_mode": "Markdown", + "disable_web_page_preview": "true", + } + + return t.Request(params) +} + +// SendMessageHTML 发送HTML格式消息 +func (t *Telegram) SendMessageHTML(text string) ([]byte, error) { + params := map[string]interface{}{ + "chat_id": t.ChatID, + "text": text, + "parse_mode": "HTML", + "disable_web_page_preview": "true", + } + + return t.Request(params) +} + +func (t *Telegram) getAPIURL() string { + // 自定义 API 地址优先级最高 + if t.ApiHost != "" { + return fmt.Sprintf("%s/bot%s/sendMessage", t.ApiHost, t.BotToken) + } + return fmt.Sprintf("https://api.telegram.org/bot%s/sendMessage", t.BotToken) +} + +// getHTTPClient 获取配置了代理的 HTTP 客户端 +func (t *Telegram) getHTTPClient() *http.Client { + client := &http.Client{ + Timeout: 30 * time.Second, + } + + // 如果配置了代理且没有自定义 API 地址,则使用代理 + // 自定义 API 地址优先级更高,通常用于自建代理服务器 + if t.ProxyURL != "" && t.ApiHost == "" { + proxyURL, err := url.Parse(t.ProxyURL) + if err == nil { + // 判断是否为 SOCKS5 代理 + if strings.HasPrefix(strings.ToLower(t.ProxyURL), "socks5://") { + // 使用 SOCKS5 代理 + dialer, err := t.createSOCKS5Dialer(proxyURL) + if err == nil { + client.Transport = &http.Transport{ + DialContext: dialer.DialContext, + } + } + } else { + // 使用 HTTP/HTTPS 代理 + client.Transport = &http.Transport{ + Proxy: http.ProxyURL(proxyURL), + } + } + } + } + + return client +} + +// createSOCKS5Dialer 创建 SOCKS5 代理拨号器 +func (t *Telegram) createSOCKS5Dialer(proxyURL *url.URL) (proxy.ContextDialer, error) { + // 解析代理地址 + host := proxyURL.Host + + // 检查是否有认证信息 + var auth *proxy.Auth + if proxyURL.User != nil { + password, _ := proxyURL.User.Password() + auth = &proxy.Auth{ + User: proxyURL.User.Username(), + Password: password, + } + } + + // 创建基础拨号器 + baseDialer := &net.Dialer{ + Timeout: 30 * time.Second, + KeepAlive: 30 * time.Second, + } + + // 创建 SOCKS5 拨号器 + dialer, err := proxy.SOCKS5("tcp", host, auth, baseDialer) + if err != nil { + return nil, err + } + + // 转换为 ContextDialer + contextDialer, ok := dialer.(proxy.ContextDialer) + if !ok { + return nil, errors.New("failed to convert to ContextDialer") + } + + return contextDialer, nil +} diff --git a/internal/sdk/message/wechat_account.go b/internal/sdk/message/wechat_account.go new file mode 100644 index 0000000..e848f85 --- /dev/null +++ b/internal/sdk/message/wechat_account.go @@ -0,0 +1,63 @@ +package message + +import ( + "github.com/silenceper/wechat/v2" + "github.com/silenceper/wechat/v2/cache" + offConfig "github.com/silenceper/wechat/v2/officialaccount/config" + "github.com/silenceper/wechat/v2/officialaccount/message" + "github.com/sirupsen/logrus" +) + +type WeChatOFAccount struct { + AppID string + AppSecret string + ToUser string + TemplateID string + URL string +} + +// 使用内存缓存进行token的存储 +var memory = cache.NewMemory() + +func (cw *WeChatOFAccount) Send(title string, content string) (string, error) { + wc := wechat.NewWechat() + cfg := &offConfig.Config{ + AppID: cw.AppID, + AppSecret: cw.AppSecret, + Cache: memory, + } + officialAccount := wc.GetOfficialAccount(cfg) + + // 获取 Access Token + _, err := officialAccount.GetAccessToken() + if err != nil { + logrus.Errorf("获取access token失败:%s", err) + return "", err + } + + msgData := make(map[string]*message.TemplateDataItem) + msgData["content"] = &message.TemplateDataItem{ + Value: content, + } + msgData["title"] = &message.TemplateDataItem{ + Value: title, + //Color: "#173177", + } + + // 创建模板消息 + templateMessage := &message.TemplateMessage{ + ToUser: cw.ToUser, + TemplateID: cw.TemplateID, + URL: cw.URL, + Data: msgData, + } + + // 发送模板消息 + _, err = officialAccount.GetTemplate().Send(templateMessage) + if err != nil { + logrus.Errorf("发送模板消息失败: %s", err) + return "", err + } + //logrus.Infof("模板消息发送成功。 消息ID: %d", msgID) + return "", nil +} diff --git a/internal/sdk/messenger/README.md b/internal/sdk/messenger/README.md new file mode 100644 index 0000000..c44be9e --- /dev/null +++ b/internal/sdk/messenger/README.md @@ -0,0 +1,161 @@ +# Messenger SDK + +统一消息发送 SDK,可独立于 Message-Push-Nest 业务层使用。 + +## 特点 + +- **零业务依赖**:不依赖数据库、路由、认证等业务代码 +- **开箱即用**:直接传配置 + 消息即可发送 +- **支持 12 种渠道**:Email, 钉钉, 企业微信, 飞书, Telegram, Bark, Ntfy, Gotify, PushMe, 自定义Webhook, 微信公众号, 阿里云短信 +- **可扩展**:通过 `RegisterChannel` 注册自定义渠道 + +## 快速使用 + +```go +import "message-nest/pkg/sdk/messenger" + +// 方式1: 直接发送 +result, err := messenger.Send("Telegram", messenger.ChannelConfig{ + "bot_token": "your-bot-token", + "chat_id": "123456", +}, &messenger.Message{ + Title: "告警通知", + Text: "服务器 CPU 超过 90%", + Markdown: "**服务器 CPU** 超过 `90%`", +}) + +if err != nil { + log.Fatal(err) +} +if !result.Success { + log.Printf("发送失败: %s", result.Error) +} +``` + +### 使用 Client(预设默认配置) + +```go +client := messenger.NewClient() + +// 预设 Telegram 配置 +client.SetDefaultConfig("Telegram", messenger.ChannelConfig{ + "bot_token": "your-bot-token", + "chat_id": "default-chat", +}) + +// 后续发送可以覆盖部分配置,也可以传 nil 使用全部默认值 +result, err := client.Send("Telegram", nil, &messenger.Message{ + Title: "Hello", + Text: "World", +}) +``` + +## 各渠道配置参数 + +### Email +| 参数 | 必填 | 说明 | +|------|------|------| +| `server` | ✅ | SMTP 服务地址 | +| `port` | ✅ | SMTP 端口 | +| `account` | ✅ | 邮箱账号 | +| `passwd` | ✅ | 邮箱密码 | +| `from_name` | ❌ | 发信人名称 | +| `to_account` | ✅ | 收件邮箱 | + +### Dtalk(钉钉) +| 参数 | 必填 | 说明 | +|------|------|------| +| `access_token` | ✅ | 钉钉 access_token | +| `secret` | ❌ | 加签秘钥 | + +### QyWeiXin(企业微信) +| 参数 | 必填 | 说明 | +|------|------|------| +| `access_token` | ✅ | 企业微信 access_token | + +### Feishu(飞书) +| 参数 | 必填 | 说明 | +|------|------|------| +| `access_token` | ✅ | 飞书 access_token | +| `secret` | ❌ | 加签秘钥 | + +### Telegram +| 参数 | 必填 | 说明 | +|------|------|------| +| `bot_token` | ✅ | Bot Token | +| `chat_id` | ✅ | Chat ID | +| `api_host` | ❌ | 自定义 API 地址 | +| `proxy_url` | ❌ | 代理地址 (http/https/socks5) | + +### Bark +| 参数 | 必填 | 说明 | +|------|------|------| +| `push_key` | ✅ | Bark Push Key | +| `archive` | ❌ | 是否存档 | +| `group` | ❌ | 推送分组 | +| `sound` | ❌ | 推送声音 | +| `icon` | ❌ | 推送图标 | +| `level` | ❌ | 时效性 | +| `url` | ❌ | 跳转URL | +| `key` | ❌ | 加密Key | +| `iv` | ❌ | 加密IV | + +### Ntfy +| 参数 | 必填 | 说明 | +|------|------|------| +| `topic` | ✅ | Topic | +| `url` | ❌ | 自定义 API 地址 | +| `priority` | ❌ | 优先级 | +| `icon` | ❌ | 图标 URL | +| `token` | ❌ | Token | +| `username` | ❌ | 用户名 | +| `password` | ❌ | 密码 | +| `actions` | ❌ | Actions | + +### Gotify +| 参数 | 必填 | 说明 | +|------|------|------| +| `url` | ✅ | Gotify 服务地址 | +| `token` | ✅ | Token | +| `priority` | ❌ | 优先级 | + +### PushMe +| 参数 | 必填 | 说明 | +|------|------|------| +| `push_key` | ✅ | PushMe Push Key | +| `url` | ❌ | 自定义 API 地址 | +| `date` | ❌ | 日期 | +| `type` | ❌ | 类型 | + +### Custom(自定义Webhook) +| 参数 | 必填 | 说明 | +|------|------|------| +| `webhook` | ✅ | Webhook URL | +| `body` | ❌ | 请求体模板(`TEXT` 占位符会被替换) | + +### WeChatOFAccount(微信公众号) +| 参数 | 必填 | 说明 | +|------|------|------| +| `appID` | ✅ | 公众号 AppID | +| `appsecret` | ✅ | 公众号 AppSecret | +| `tempid` | ❌ | 模板消息 ID | +| `to_account` | ✅ | 接收者 OpenID | + +### AliyunSMS(阿里云短信) +| 参数 | 必填 | 说明 | +|------|------|------| +| `access_key_id` | ✅ | AccessKeyId | +| `access_key_secret` | ✅ | AccessKeySecret | +| `sign_name` | ✅ | 短信签名 | +| `region_id` | ❌ | 区域ID(默认 cn-hangzhou) | +| `phone_number` | ✅ | 手机号码 | +| `template_code` | ✅ | 短信模板 CODE | + +## 自定义扩展 + +```go +// 注册自定义渠道 +messenger.RegisterChannel("MyChannel", func() messenger.Channel { + return &myCustomChannel{} +}) +``` diff --git a/internal/sdk/messenger/channels/aliyun_sms.go b/internal/sdk/messenger/channels/aliyun_sms.go new file mode 100644 index 0000000..7bf6e43 --- /dev/null +++ b/internal/sdk/messenger/channels/aliyun_sms.go @@ -0,0 +1,40 @@ +package channels + +type AliyunSMSChannel struct{ *BaseChannel } + +func NewAliyunSMSChannel() Channel { + return &AliyunSMSChannel{NewBaseChannel(ChannelAliyunSMS, []string{FormatTypeText})} +} + +func (c *AliyunSMSChannel) Send(config ChannelConfig, msg *Message) (*Result, error) { + accessKeyId := config.GetString("access_key_id") + accessKeySecret := config.GetString("access_key_secret") + signName := config.GetString("sign_name") + regionId := config.GetString("region_id") + phoneNumber := config.GetString("phone_number") + templateCode := config.GetString("template_code") + + if accessKeyId == "" || accessKeySecret == "" || signName == "" { + return SendError("aliyun sms config missing: access_key_id, access_key_secret, sign_name are required"), nil + } + if phoneNumber == "" || templateCode == "" { + return SendError("aliyun sms config missing: phone_number, template_code are required"), nil + } + + _, formattedContent := c.FormatContent(msg) + + if regionId == "" { + regionId = "cn-hangzhou" + } + + client, err := createAliyunSMSClient(accessKeyId, accessKeySecret, regionId) + if err != nil { + return SendError("创建阿里云短信客户端失败: %s", err.Error()), nil + } + + result, err := sendAliyunSMS(client, phoneNumber, signName, templateCode, formattedContent, msg.Extra) + if err != nil { + return ErrorResult("", err), nil + } + return SuccessResult(result), nil +} diff --git a/internal/sdk/messenger/channels/bark.go b/internal/sdk/messenger/channels/bark.go new file mode 100644 index 0000000..b1b0887 --- /dev/null +++ b/internal/sdk/messenger/channels/bark.go @@ -0,0 +1,34 @@ +package channels + +import "github.com/engigu/baihu-panel/internal/sdk/message" + +type BarkChannel struct{ *BaseChannel } + +func NewBarkChannel() Channel { + return &BarkChannel{NewBaseChannel(ChannelBark, []string{FormatTypeText})} +} + +func (c *BarkChannel) Send(config ChannelConfig, msg *Message) (*Result, error) { + pushKey := config.GetString("push_key") + if pushKey == "" { + return SendError("bark config missing: push_key is required"), nil + } + + cli := message.Bark{ + PushKey: pushKey, + Archive: config.GetString("archive"), + Group: config.GetString("group"), + Sound: config.GetString("sound"), + Icon: config.GetString("icon"), + Level: config.GetString("level"), + URL: config.GetString("url"), + Key: config.GetString("key"), + IV: config.GetString("iv"), + } + + res, err := cli.Request(msg.Title, msg.Text) + if err != nil { + return ErrorResult(string(res), err), nil + } + return SuccessResult(string(res)), nil +} diff --git a/internal/sdk/messenger/channels/channel.go b/internal/sdk/messenger/channels/channel.go new file mode 100644 index 0000000..6a5a606 --- /dev/null +++ b/internal/sdk/messenger/channels/channel.go @@ -0,0 +1,74 @@ +package channels + +import "fmt" + +// Channel 渠道接口 - SDK 版本,零业务依赖 +type Channel interface { + // GetType 返回渠道类型标识 + GetType() string + // GetSupportedFormats 返回支持的消息格式 + GetSupportedFormats() []string + // Send 发送消息 + Send(config ChannelConfig, msg *Message) (*Result, error) +} + +// BaseChannel 渠道基础实现 +type BaseChannel struct { + channelType string + supportedFormats []string +} + +func NewBaseChannel(channelType string, supportedFormats []string) *BaseChannel { + return &BaseChannel{channelType: channelType, supportedFormats: supportedFormats} +} + +func (c *BaseChannel) GetType() string { return c.channelType } +func (c *BaseChannel) GetSupportedFormats() []string { return c.supportedFormats } + +// FormatContent 根据渠道支持的格式选择最佳内容 +func (c *BaseChannel) FormatContent(msg *Message) (formatType string, content string) { + for _, ft := range c.supportedFormats { + switch ft { + case FormatTypeMarkdown: + if msg.HasMarkdown() { + return FormatTypeMarkdown, msg.Markdown + } + case FormatTypeHTML: + if msg.HasHTML() { + return FormatTypeHTML, msg.HTML + } + case FormatTypeText: + if msg.HasText() { + return FormatTypeText, msg.Text + } + } + } + if msg.HasText() { + return FormatTypeText, msg.Text + } + return FormatTypeText, "" +} + +// SuccessResult 创建成功结果 +func SuccessResult(response string) *Result { + return &Result{Success: true, Response: response} +} + +// ErrorResult 创建失败结果 +func ErrorResult(response string, err error) *Result { + errMsg := "" + if err != nil { + errMsg = err.Error() + } + return &Result{Success: false, Response: response, Error: errMsg} +} + +// ErrorResultStr 创建失败结果(字符串错误) +func ErrorResultStr(response string, errMsg string) *Result { + return &Result{Success: false, Response: response, Error: errMsg} +} + +// SendError 发送失败时的格式化错误 +func SendError(format string, args ...any) *Result { + return &Result{Success: false, Error: fmt.Sprintf(format, args...)} +} diff --git a/internal/sdk/messenger/channels/custom.go b/internal/sdk/messenger/channels/custom.go new file mode 100644 index 0000000..89a5836 --- /dev/null +++ b/internal/sdk/messenger/channels/custom.go @@ -0,0 +1,35 @@ +package channels + +import "github.com/engigu/baihu-panel/internal/sdk/message" + +type CustomChannel struct{ *BaseChannel } + +func NewCustomChannel() Channel { + return &CustomChannel{NewBaseChannel(ChannelCustom, []string{FormatTypeText})} +} + +func (c *CustomChannel) Send(config ChannelConfig, msg *Message) (*Result, error) { + webhook := config.GetString("webhook") + body := config.GetString("body") + + if webhook == "" { + return SendError("custom config missing: webhook is required"), nil + } + + _, formattedContent := c.FormatContent(msg) + cli := message.CustomWebhook{} + + // 替换 body 模板中的 TEXT 占位符 + bodyStr := body + if bodyStr != "" { + bodyStr = replaceBodyPlaceholder(bodyStr, formattedContent) + } else { + bodyStr = formattedContent + } + + res, err := cli.Request(webhook, bodyStr) + if err != nil { + return ErrorResult(string(res), err), nil + } + return SuccessResult(string(res)), nil +} diff --git a/internal/sdk/messenger/channels/dtalk.go b/internal/sdk/messenger/channels/dtalk.go new file mode 100644 index 0000000..4e9cc8c --- /dev/null +++ b/internal/sdk/messenger/channels/dtalk.go @@ -0,0 +1,41 @@ +package channels + +import "github.com/engigu/baihu-panel/internal/sdk/message" + +type DtalkChannel struct{ *BaseChannel } + +func NewDtalkChannel() Channel { + return &DtalkChannel{NewBaseChannel(ChannelDtalk, []string{FormatTypeMarkdown, FormatTypeText})} +} + +func (c *DtalkChannel) Send(config ChannelConfig, msg *Message) (*Result, error) { + accessToken := config.GetString("access_token") + secret := config.GetString("secret") + + if accessToken == "" { + return SendError("dtalk config missing: access_token is required"), nil + } + + contentType, formattedContent := c.FormatContent(msg) + atMobiles := msg.GetAtMobiles() + if msg.AtAll { + atMobiles = append(atMobiles, "all") + } + + cli := message.Dtalk{AccessToken: accessToken, Secret: secret} + var res []byte + var err error + + if contentType == FormatTypeText { + res, err = cli.SendMessageText(formattedContent, atMobiles...) + } else if contentType == FormatTypeMarkdown { + res, err = cli.SendMessageMarkdown(msg.Title, formattedContent, atMobiles...) + } else { + return SendError("未知的钉钉发送内容类型:%s", contentType), nil + } + + if err != nil { + return ErrorResult(string(res), err), nil + } + return SuccessResult(string(res)), nil +} diff --git a/internal/sdk/messenger/channels/email.go b/internal/sdk/messenger/channels/email.go new file mode 100644 index 0000000..45b4056 --- /dev/null +++ b/internal/sdk/messenger/channels/email.go @@ -0,0 +1,49 @@ +package channels + +import ( + "fmt" + "github.com/engigu/baihu-panel/internal/sdk/message" + "strconv" +) + +type EmailChannel struct{ *BaseChannel } + +func NewEmailChannel() Channel { + return &EmailChannel{NewBaseChannel(ChannelEmail, []string{FormatTypeHTML, FormatTypeText})} +} + +func (c *EmailChannel) Send(config ChannelConfig, msg *Message) (*Result, error) { + server := config.GetString("server") + portStr := config.GetString("port") + account := config.GetString("account") + passwd := config.GetString("passwd") + fromName := config.GetString("from_name") + toAccount := config.GetString("to_account") + + if server == "" || account == "" || passwd == "" { + return SendError("email config missing: server, account, passwd are required"), nil + } + if toAccount == "" { + return SendError("email config missing: to_account is required"), nil + } + + port, _ := strconv.Atoi(portStr) + contentType, formattedContent := c.FormatContent(msg) + + var emailer message.EmailMessage + emailer.Init(server, port, account, passwd, fromName) + + var errMsg string + if contentType == FormatTypeText { + errMsg = emailer.SendTextMessage(toAccount, msg.Title, formattedContent) + } else if contentType == FormatTypeHTML { + errMsg = emailer.SendHtmlMessage(toAccount, msg.Title, formattedContent) + } else { + errMsg = fmt.Sprintf("未知的邮件发送内容类型:%s", contentType) + } + + if errMsg != "" { + return ErrorResultStr("", errMsg), nil + } + return SuccessResult(""), nil +} diff --git a/internal/sdk/messenger/channels/feishu.go b/internal/sdk/messenger/channels/feishu.go new file mode 100644 index 0000000..ecf23f2 --- /dev/null +++ b/internal/sdk/messenger/channels/feishu.go @@ -0,0 +1,43 @@ +package channels + +import "github.com/engigu/baihu-panel/internal/sdk/message" + +type FeishuChannel struct{ *BaseChannel } + +func NewFeishuChannel() Channel { + return &FeishuChannel{NewBaseChannel(ChannelFeishu, []string{FormatTypeMarkdown, FormatTypeText})} +} + +func (c *FeishuChannel) Send(config ChannelConfig, msg *Message) (*Result, error) { + accessToken := config.GetString("access_token") + secret := config.GetString("secret") + + if accessToken == "" { + return SendError("feishu config missing: access_token is required"), nil + } + + contentType, formattedContent := c.FormatContent(msg) + atMobiles := msg.GetAtMobiles() + atUserIds := msg.GetAtUserIds() + atList := append(atMobiles, atUserIds...) + if msg.AtAll { + atList = append(atList, "all") + } + + cli := message.Feishu{AccessToken: accessToken, Secret: secret} + var res []byte + var err error + + if contentType == FormatTypeText { + res, err = cli.SendMessageText(formattedContent, atList...) + } else if contentType == FormatTypeMarkdown { + res, err = cli.SendMessageMarkdown(msg.Title, formattedContent, atList...) + } else { + return SendError("未知的飞书发送内容类型:%s", contentType), nil + } + + if err != nil { + return ErrorResult(string(res), err), nil + } + return SuccessResult(string(res)), nil +} diff --git a/internal/sdk/messenger/channels/gotify.go b/internal/sdk/messenger/channels/gotify.go new file mode 100644 index 0000000..94b1d78 --- /dev/null +++ b/internal/sdk/messenger/channels/gotify.go @@ -0,0 +1,34 @@ +package channels + +import ( + "github.com/engigu/baihu-panel/internal/sdk/message" + "strconv" +) + +type GotifyChannel struct{ *BaseChannel } + +func NewGotifyChannel() Channel { + return &GotifyChannel{NewBaseChannel(ChannelGotify, []string{FormatTypeText})} +} + +func (c *GotifyChannel) Send(config ChannelConfig, msg *Message) (*Result, error) { + url := config.GetString("url") + token := config.GetString("token") + + if url == "" || token == "" { + return SendError("gotify config missing: url and token are required"), nil + } + + priority, _ := strconv.Atoi(config.GetString("priority")) + cli := message.Gotify{ + Url: url, + Token: token, + Priority: priority, + } + + res, err := cli.Request(msg.Title, msg.Text) + if err != nil { + return ErrorResult(string(res), err), nil + } + return SuccessResult(string(res)), nil +} diff --git a/internal/sdk/messenger/channels/helpers.go b/internal/sdk/messenger/channels/helpers.go new file mode 100644 index 0000000..92aa6de --- /dev/null +++ b/internal/sdk/messenger/channels/helpers.go @@ -0,0 +1,52 @@ +package channels + +import ( + "encoding/json" + "fmt" + "strings" + + "github.com/aliyun/alibaba-cloud-sdk-go/services/dysmsapi" +) + +// replaceBodyPlaceholder 替换自定义 webhook body 中的 TEXT 占位符 +func replaceBodyPlaceholder(body string, content string) string { + data, _ := json.Marshal(content) + dataStr := strings.Trim(string(data), "\"") + return strings.Replace(body, "TEXT", dataStr, -1) +} + +// createAliyunSMSClient 创建阿里云短信客户端 +func createAliyunSMSClient(accessKeyId, accessKeySecret, regionId string) (*dysmsapi.Client, error) { + return dysmsapi.NewClientWithAccessKey(regionId, accessKeyId, accessKeySecret) +} + +// sendAliyunSMS 发送短信 +func sendAliyunSMS(client *dysmsapi.Client, phoneNumber, signName, templateCode, content string, extra map[string]any) (string, error) { + templateParam := map[string]interface{}{ + "content": content, + } + if extra != nil { + for k, v := range extra { + templateParam[k] = v + } + } + templateParamJSON, _ := json.Marshal(templateParam) + + request := dysmsapi.CreateSendSmsRequest() + request.Scheme = "https" + request.PhoneNumbers = phoneNumber + request.SignName = signName + request.TemplateCode = templateCode + request.TemplateParam = string(templateParamJSON) + + response, err := client.SendSms(request) + if err != nil { + return "", fmt.Errorf("发送短信失败: %s", err.Error()) + } + + if response.Code != "OK" { + return "", fmt.Errorf("发送失败: %s - %s", response.Code, response.Message) + } + + return fmt.Sprintf("RequestId: %s, BizId: %s", response.RequestId, response.BizId), nil +} diff --git a/internal/sdk/messenger/channels/ntfy.go b/internal/sdk/messenger/channels/ntfy.go new file mode 100644 index 0000000..441df95 --- /dev/null +++ b/internal/sdk/messenger/channels/ntfy.go @@ -0,0 +1,33 @@ +package channels + +import "github.com/engigu/baihu-panel/internal/sdk/message" + +type NtfyChannel struct{ *BaseChannel } + +func NewNtfyChannel() Channel { + return &NtfyChannel{NewBaseChannel(ChannelNtfy, []string{FormatTypeText})} +} + +func (c *NtfyChannel) Send(config ChannelConfig, msg *Message) (*Result, error) { + topic := config.GetString("topic") + if topic == "" { + return SendError("ntfy config missing: topic is required"), nil + } + + cli := message.Ntfy{ + Url: config.GetString("url"), + Topic: topic, + Priority: config.GetString("priority"), + Icon: config.GetString("icon"), + Token: config.GetString("token"), + Username: config.GetString("username"), + Password: config.GetString("password"), + Actions: config.GetString("actions"), + } + + res, err := cli.Request(msg.Title, msg.Text) + if err != nil { + return ErrorResult(string(res), err), nil + } + return SuccessResult(string(res)), nil +} diff --git a/internal/sdk/messenger/channels/pushme.go b/internal/sdk/messenger/channels/pushme.go new file mode 100644 index 0000000..78a15fb --- /dev/null +++ b/internal/sdk/messenger/channels/pushme.go @@ -0,0 +1,29 @@ +package channels + +import "github.com/engigu/baihu-panel/internal/sdk/message" + +type PushMeChannel struct{ *BaseChannel } + +func NewPushMeChannel() Channel { + return &PushMeChannel{NewBaseChannel(ChannelPushMe, []string{FormatTypeText})} +} + +func (c *PushMeChannel) Send(config ChannelConfig, msg *Message) (*Result, error) { + pushKey := config.GetString("push_key") + if pushKey == "" { + return SendError("pushme config missing: push_key is required"), nil + } + + cli := message.PushMe{ + PushKey: pushKey, + URL: config.GetString("url"), + Date: config.GetString("date"), + Type: config.GetString("type"), + } + + res, err := cli.Request(msg.Title, msg.Text) + if err != nil { + return ErrorResult(res, err), nil + } + return SuccessResult(res), nil +} diff --git a/internal/sdk/messenger/channels/qyweixin.go b/internal/sdk/messenger/channels/qyweixin.go new file mode 100644 index 0000000..b9b706c --- /dev/null +++ b/internal/sdk/messenger/channels/qyweixin.go @@ -0,0 +1,42 @@ +package channels + +import "github.com/engigu/baihu-panel/internal/sdk/message" + +type QyWeiXinChannel struct{ *BaseChannel } + +func NewQyWeiXinChannel() Channel { + return &QyWeiXinChannel{NewBaseChannel(ChannelQyWeiXin, []string{FormatTypeMarkdown, FormatTypeText})} +} + +func (c *QyWeiXinChannel) Send(config ChannelConfig, msg *Message) (*Result, error) { + accessToken := config.GetString("access_token") + + if accessToken == "" { + return SendError("qyweixin config missing: access_token is required"), nil + } + + contentType, formattedContent := c.FormatContent(msg) + atList := []string{} + atList = append(atList, msg.GetAtUserIds()...) + atList = append(atList, msg.GetAtMobiles()...) + if msg.AtAll { + atList = append(atList, "@all") + } + + cli := message.QyWeiXin{AccessToken: accessToken} + var res []byte + var err error + + if contentType == FormatTypeText { + res, err = cli.SendMessageText(formattedContent, atList...) + } else if contentType == FormatTypeMarkdown { + res, err = cli.SendMessageMarkdown(msg.Title, formattedContent, atList...) + } else { + return SendError("未知的企业微信发送内容类型:%s", contentType), nil + } + + if err != nil { + return ErrorResult(string(res), err), nil + } + return SuccessResult(string(res)), nil +} diff --git a/internal/sdk/messenger/channels/telegram.go b/internal/sdk/messenger/channels/telegram.go new file mode 100644 index 0000000..4d63bec --- /dev/null +++ b/internal/sdk/messenger/channels/telegram.go @@ -0,0 +1,47 @@ +package channels + +import "github.com/engigu/baihu-panel/internal/sdk/message" + +type TelegramChannel struct{ *BaseChannel } + +func NewTelegramChannel() Channel { + return &TelegramChannel{NewBaseChannel(ChannelTelegram, []string{FormatTypeMarkdown, FormatTypeHTML, FormatTypeText})} +} + +func (c *TelegramChannel) Send(config ChannelConfig, msg *Message) (*Result, error) { + botToken := config.GetString("bot_token") + chatID := config.GetString("chat_id") + apiHost := config.GetString("api_host") + proxyURL := config.GetString("proxy_url") + + if botToken == "" || chatID == "" { + return SendError("telegram config missing: bot_token, chat_id are required"), nil + } + + contentType, formattedContent := c.FormatContent(msg) + cli := message.Telegram{ + BotToken: botToken, + ChatID: chatID, + ApiHost: apiHost, + ProxyURL: proxyURL, + } + + var res []byte + var err error + + switch contentType { + case FormatTypeText: + res, err = cli.SendMessageText(formattedContent) + case FormatTypeMarkdown: + res, err = cli.SendMessageMarkdown(formattedContent) + case FormatTypeHTML: + res, err = cli.SendMessageHTML(formattedContent) + default: + return SendError("未知的Telegram发送内容类型:%s", contentType), nil + } + + if err != nil { + return ErrorResult(string(res), err), nil + } + return SuccessResult(string(res)), nil +} diff --git a/internal/sdk/messenger/channels/types.go b/internal/sdk/messenger/channels/types.go new file mode 100644 index 0000000..f8d69ce --- /dev/null +++ b/internal/sdk/messenger/channels/types.go @@ -0,0 +1,75 @@ +package channels + +// Message 统一消息内容 +type Message struct { + Title string `json:"title"` + Text string `json:"text"` + HTML string `json:"html"` + Markdown string `json:"markdown"` + URL string `json:"url"` + ImageURL string `json:"image_url"` + Summary string `json:"summary"` + AtMobiles []string `json:"at_mobiles"` + AtUserIds []string `json:"at_user_ids"` + AtAll bool `json:"at_all"` + Extra map[string]any `json:"extra"` +} + +func (m *Message) HasText() bool { return m.Text != "" } +func (m *Message) HasHTML() bool { return m.HTML != "" } +func (m *Message) HasMarkdown() bool { return m.Markdown != "" } + +func (m *Message) GetAtMobiles() []string { + if m.AtMobiles == nil { + return []string{} + } + return m.AtMobiles +} + +func (m *Message) GetAtUserIds() []string { + if m.AtUserIds == nil { + return []string{} + } + return m.AtUserIds +} + +// ChannelConfig 渠道认证配置(Key-Value 形式,各渠道自行定义字段) +type ChannelConfig map[string]string + +// GetString 安全获取配置值 +func (c ChannelConfig) GetString(key string) string { + if v, ok := c[key]; ok { + return v + } + return "" +} + +// Result 发送结果 +type Result struct { + Success bool `json:"success"` + Response string `json:"response"` // 原始响应 + Error string `json:"error"` // 错误信息 +} + +// 消息格式类型常量 +const ( + FormatTypeText = "text" + FormatTypeHTML = "html" + FormatTypeMarkdown = "markdown" +) + +// 渠道类型常量 +const ( + ChannelEmail = "Email" + ChannelDtalk = "Dtalk" + ChannelQyWeiXin = "QyWeiXin" + ChannelFeishu = "Feishu" + ChannelCustom = "Custom" + ChannelWeChatOFAccount = "WeChatOFAccount" + ChannelAliyunSMS = "AliyunSMS" + ChannelTelegram = "Telegram" + ChannelBark = "Bark" + ChannelPushMe = "PushMe" + ChannelNtfy = "Ntfy" + ChannelGotify = "Gotify" +) diff --git a/internal/sdk/messenger/channels/wechat.go b/internal/sdk/messenger/channels/wechat.go new file mode 100644 index 0000000..d457ba9 --- /dev/null +++ b/internal/sdk/messenger/channels/wechat.go @@ -0,0 +1,38 @@ +package channels + +import "github.com/engigu/baihu-panel/internal/sdk/message" + +type WeChatOFAccountChannel struct{ *BaseChannel } + +func NewWeChatOFAccountChannel() Channel { + return &WeChatOFAccountChannel{NewBaseChannel(ChannelWeChatOFAccount, []string{FormatTypeText})} +} + +func (c *WeChatOFAccountChannel) Send(config ChannelConfig, msg *Message) (*Result, error) { + appID := config.GetString("appID") + appSecret := config.GetString("appsecret") + tempID := config.GetString("tempid") + toAccount := config.GetString("to_account") + + if appID == "" || appSecret == "" { + return SendError("wechat config missing: appID, appsecret are required"), nil + } + if toAccount == "" { + return SendError("wechat config missing: to_account is required"), nil + } + + _, formattedContent := c.FormatContent(msg) + cli := message.WeChatOFAccount{ + AppID: appID, + AppSecret: appSecret, + TemplateID: tempID, + ToUser: toAccount, + URL: msg.URL, + } + + res, err := cli.Send(msg.Title, formattedContent) + if err != nil { + return ErrorResult(res, err), nil + } + return SuccessResult(res), nil +} diff --git a/internal/sdk/messenger/messenger.go b/internal/sdk/messenger/messenger.go new file mode 100644 index 0000000..a31eae2 --- /dev/null +++ b/internal/sdk/messenger/messenger.go @@ -0,0 +1,195 @@ +// Package messenger 提供统一的消息发送SDK +// +// 此包可独立于 Message-Push-Nest 的业务层(数据库、HTTP路由等)使用 +// 适合在其他服务中直接引入来发送消息 +// +// 快速使用: +// +// result, err := messenger.Send("Telegram", messenger.ChannelConfig{ +// "bot_token": "your-bot-token", +// "chat_id": "your-chat-id", +// }, &messenger.Message{ +// Title: "Hello", +// Text: "World", +// }) +package messenger + +import ( + "fmt" + "github.com/engigu/baihu-panel/internal/sdk/messenger/channels" + "sync" +) + +// 重导出 channels 包的类型,方便外部使用 +type ( + Channel = channels.Channel + Message = channels.Message + ChannelConfig = channels.ChannelConfig + Result = channels.Result + BaseChannel = channels.BaseChannel +) + +// 重导出常量 +const ( + FormatTypeText = channels.FormatTypeText + FormatTypeHTML = channels.FormatTypeHTML + FormatTypeMarkdown = channels.FormatTypeMarkdown + + ChannelEmail = channels.ChannelEmail + ChannelDtalk = channels.ChannelDtalk + ChannelQyWeiXin = channels.ChannelQyWeiXin + ChannelFeishu = channels.ChannelFeishu + ChannelCustom = channels.ChannelCustom + ChannelWeChatOFAccount = channels.ChannelWeChatOFAccount + ChannelAliyunSMS = channels.ChannelAliyunSMS + ChannelTelegram = channels.ChannelTelegram + ChannelBark = channels.ChannelBark + ChannelPushMe = channels.ChannelPushMe + ChannelNtfy = channels.ChannelNtfy + ChannelGotify = channels.ChannelGotify +) + +// 重导出辅助函数 +var ( + SuccessResult = channels.SuccessResult + ErrorResult = channels.ErrorResult + ErrorResultStr = channels.ErrorResultStr + SendError = channels.SendError + NewBaseChannel = channels.NewBaseChannel +) + +// channelFactory 渠道工厂注册表 +var ( + channelFactories = map[string]func() Channel{} + factoryMu sync.RWMutex +) + +func init() { + // 注册所有内置渠道 + RegisterChannel(ChannelEmail, func() Channel { return channels.NewEmailChannel() }) + RegisterChannel(ChannelDtalk, func() Channel { return channels.NewDtalkChannel() }) + RegisterChannel(ChannelQyWeiXin, func() Channel { return channels.NewQyWeiXinChannel() }) + RegisterChannel(ChannelFeishu, func() Channel { return channels.NewFeishuChannel() }) + RegisterChannel(ChannelTelegram, func() Channel { return channels.NewTelegramChannel() }) + RegisterChannel(ChannelBark, func() Channel { return channels.NewBarkChannel() }) + RegisterChannel(ChannelNtfy, func() Channel { return channels.NewNtfyChannel() }) + RegisterChannel(ChannelGotify, func() Channel { return channels.NewGotifyChannel() }) + RegisterChannel(ChannelPushMe, func() Channel { return channels.NewPushMeChannel() }) + RegisterChannel(ChannelCustom, func() Channel { return channels.NewCustomChannel() }) + RegisterChannel(ChannelWeChatOFAccount, func() Channel { return channels.NewWeChatOFAccountChannel() }) + RegisterChannel(ChannelAliyunSMS, func() Channel { return channels.NewAliyunSMSChannel() }) +} + +// RegisterChannel 注册自定义渠道(可用于扩展) +func RegisterChannel(channelType string, factory func() Channel) { + factoryMu.Lock() + defer factoryMu.Unlock() + channelFactories[channelType] = factory +} + +// GetChannel 获取渠道实例 +func GetChannel(channelType string) (Channel, error) { + factoryMu.RLock() + defer factoryMu.RUnlock() + factory, ok := channelFactories[channelType] + if !ok { + return nil, fmt.Errorf("未知的渠道类型: %s", channelType) + } + return factory(), nil +} + +// ListChannels 列出所有已注册的渠道类型 +func ListChannels() []string { + factoryMu.RLock() + defer factoryMu.RUnlock() + types := make([]string, 0, len(channelFactories)) + for t := range channelFactories { + types = append(types, t) + } + return types +} + +// Send 发送消息的便捷函数 +// +// 参数: +// - channelType: 渠道类型(如 "Telegram", "Dtalk" 等) +// - config: 渠道必要的认证配置 +// - msg: 消息内容 +// +// 使用示例: +// +// result, err := messenger.Send("Ntfy", messenger.ChannelConfig{ +// "topic": "my-topic", +// }, &messenger.Message{ +// Title: "Alert", +// Text: "Something happened!", +// }) +func Send(channelType string, config ChannelConfig, msg *Message) (*Result, error) { + ch, err := GetChannel(channelType) + if err != nil { + return nil, err + } + return ch.Send(config, msg) +} + +// Client 消息发送客户端(支持预设默认配置) +type Client struct { + defaultConfigs map[string]ChannelConfig + mu sync.RWMutex +} + +// NewClient 创建消息发送客户端 +func NewClient() *Client { + return &Client{ + defaultConfigs: make(map[string]ChannelConfig), + } +} + +// SetDefaultConfig 为指定渠道设置默认配置 +// +// 使用示例: +// +// client := messenger.NewClient() +// client.SetDefaultConfig("Telegram", messenger.ChannelConfig{ +// "bot_token": "default-token", +// "chat_id": "default-chat", +// }) +// // 后续发送时不需要再传 config 的相关字段 +// result, err := client.Send("Telegram", nil, &messenger.Message{...}) +func (c *Client) SetDefaultConfig(channelType string, config ChannelConfig) { + c.mu.Lock() + defer c.mu.Unlock() + c.defaultConfigs[channelType] = config +} + +// Send 使用客户端发送消息(会合并默认配置) +func (c *Client) Send(channelType string, config ChannelConfig, msg *Message) (*Result, error) { + mergedConfig := c.mergeConfig(channelType, config) + return Send(channelType, mergedConfig, msg) +} + +func (c *Client) mergeConfig(channelType string, config ChannelConfig) ChannelConfig { + c.mu.RLock() + defer c.mu.RUnlock() + + defaultConfig, hasDefault := c.defaultConfigs[channelType] + if !hasDefault && config == nil { + return ChannelConfig{} + } + if !hasDefault { + return config + } + if config == nil { + return defaultConfig + } + + // 合并:config 覆盖 defaultConfig + merged := make(ChannelConfig, len(defaultConfig)+len(config)) + for k, v := range defaultConfig { + merged[k] = v + } + for k, v := range config { + merged[k] = v + } + return merged +} diff --git a/internal/services/notification_service.go b/internal/services/notification_service.go new file mode 100644 index 0000000..c2d4d59 --- /dev/null +++ b/internal/services/notification_service.go @@ -0,0 +1,323 @@ +package services + +import ( + "encoding/json" + "fmt" + "sync" + + "github.com/engigu/baihu-panel/internal/constant" + "github.com/engigu/baihu-panel/internal/database" + "github.com/engigu/baihu-panel/internal/logger" + "github.com/engigu/baihu-panel/internal/models" + "github.com/engigu/baihu-panel/internal/utils" + + "github.com/engigu/baihu-panel/internal/sdk/messenger" +) + +// NotifyChannel 通知渠道配置 +type NotifyChannel struct { + ID string `json:"id"` + Name string `json:"name"` + Type string `json:"type"` + Enabled bool `json:"enabled"` + Config map[string]string `json:"config"` +} + +// NotifyMessage 通知消息 +type NotifyMessage struct { + Title string `json:"title"` + Text string `json:"text"` +} + +// NotifyResult 发送结果 +type NotifyResult struct { + Success bool `json:"success"` + Error string `json:"error,omitempty"` +} + +// SupportedChannelTypes 支持的渠道类型 +var SupportedChannelTypes = []map[string]string{ + {"type": messenger.ChannelTelegram, "label": "Telegram"}, + {"type": messenger.ChannelBark, "label": "Bark"}, + {"type": messenger.ChannelDtalk, "label": "钉钉"}, + {"type": messenger.ChannelQyWeiXin, "label": "企业微信"}, + {"type": messenger.ChannelFeishu, "label": "飞书"}, + {"type": messenger.ChannelEmail, "label": "邮件"}, + {"type": messenger.ChannelCustom, "label": "自定义Webhook"}, + {"type": messenger.ChannelNtfy, "label": "Ntfy"}, + {"type": messenger.ChannelGotify, "label": "Gotify"}, + {"type": messenger.ChannelPushMe, "label": "PushMe"}, + // {"type": messenger.ChannelWeChatOFAccount, "label": "微信公众号"}, + {"type": messenger.ChannelAliyunSMS, "label": "阿里云短信"}, +} + +// SupportedEvents 支持的事件类型 +var SupportedEvents = []map[string]string{ + {"type": constant.EventUserLogin, "label": "用户登录", "binding_type": constant.BindingTypeSystem}, + {"type": constant.EventBruteForceLogin, "label": "密码多次错误", "binding_type": constant.BindingTypeSystem}, + {"type": constant.EventPasswordChanged, "label": "密码修改", "binding_type": constant.BindingTypeSystem}, + {"type": constant.EventTaskSuccess, "label": "任务成功", "binding_type": constant.BindingTypeTask}, + {"type": constant.EventTaskFailed, "label": "任务失败", "binding_type": constant.BindingTypeTask}, + {"type": constant.EventTaskTimeout, "label": "任务超时", "binding_type": constant.BindingTypeTask}, +} + +type NotificationService struct { + settingsService *SettingsService + mu sync.RWMutex +} + +func NewNotificationService() *NotificationService { + return &NotificationService{ + settingsService: NewSettingsService(), + } +} + +// GetChannels 获取所有渠道 +func (s *NotificationService) GetChannels() []NotifyChannel { + s.mu.RLock() + defer s.mu.RUnlock() + return s.getChannelsInternal() +} + +// SaveChannel 保存/更新渠道 +func (s *NotificationService) SaveChannel(channel NotifyChannel) error { + s.mu.Lock() + defer s.mu.Unlock() + + configJSON, err := json.Marshal(channel.Config) + if err != nil { + return err + } + + if channel.ID == "" { + // 新建 + channel.ID = utils.GenerateID() + notifyWay := &models.NotifyWay{ + ID: channel.ID, + Name: channel.Name, + Type: channel.Type, + Config: string(configJSON), + Enabled: channel.Enabled, + } + return database.DB.Create(notifyWay).Error + } + + // 更新 + updates := map[string]interface{}{ + "name": channel.Name, + "type": channel.Type, + "config": string(configJSON), + "enabled": channel.Enabled, + } + return database.DB.Model(&models.NotifyWay{}).Where("id = ?", channel.ID).Updates(updates).Error +} + +// DeleteChannel 删除渠道 +func (s *NotificationService) DeleteChannel(id string) error { + s.mu.Lock() + defer s.mu.Unlock() + + // 检查渠道是否存在 + var count int64 + database.DB.Model(&models.NotifyWay{}).Where("id = ?", id).Count(&count) + if count == 0 { + return fmt.Errorf("渠道 %s 不存在", id) + } + + // 删除渠道 + if err := database.DB.Unscoped().Where("id = ?", id).Delete(&models.NotifyWay{}).Error; err != nil { + return err + } + + // 同时清理事件绑定中引用此渠道的配置 + if err := database.DB.Unscoped().Where("way_id = ?", id).Delete(&models.NotifyBinding{}).Error; err != nil { + logger.Errorf("[Notify] 清理事件绑定失败: %v", err) + } + + return nil +} + +// GetBindings 获取事件绑定列表(新接口,用于前端展示) +func (s *NotificationService) GetBindings() []models.NotifyBinding { + var bindings []models.NotifyBinding + database.DB.Find(&bindings) + return bindings +} + +// SaveBinding 保存事件绑定 +func (s *NotificationService) SaveBinding(binding *models.NotifyBinding) error { + if binding.ID == "" { + // 检查是否已经存在相同的绑定(避免重复点击导致多个记录) + var existing models.NotifyBinding + err := database.DB.Where("type = ? AND event = ? AND way_id = ? AND data_id = ?", + binding.Type, binding.Event, binding.WayID, binding.DataID).First(&existing).Error + if err == nil { + // 如果已存在且未删除,直接返回(或者更新它) + *binding = existing + return nil + } + + binding.ID = utils.GenerateID() + return database.DB.Create(binding).Error + } + return database.DB.Save(binding).Error +} + +// DeleteBinding 删除事件绑定 +func (s *NotificationService) DeleteBinding(id string) error { + return database.DB.Unscoped().Where("id = ?", id).Delete(&models.NotifyBinding{}).Error +} + +// GetBindingsByEvent 根据事件类型和数据ID获取绑定 +func (s *NotificationService) GetBindingsByEvent(bindingType, event, dataID string) []models.NotifyBinding { + var bindings []models.NotifyBinding + + // 如果是任务事件且带有 dataID,只获取特定任务的绑定(禁用全局任务配置) + if bindingType == constant.BindingTypeTask && dataID != "" { + database.DB.Where("type = ? AND event = ? AND data_id = ?", constant.BindingTypeTask, event, dataID).Find(&bindings) + return bindings + } + + // 对于系统事件或其他情况 + query := database.DB.Where("event = ?", event) + if bindingType != "" { + query = query.Where("type = ?", bindingType) + } + + if dataID != "" { + query = query.Where("data_id = ?", dataID) + } else { + query = query.Where("data_id = ? OR data_id IS NULL", "") + } + + query.Find(&bindings) + return bindings +} + +// SendToChannel 使用 messenger SDK 发送通知到指定渠道 +func (s *NotificationService) SendToChannel(channel NotifyChannel, msg *NotifyMessage) *NotifyResult { + result, err := messenger.Send(channel.Type, messenger.ChannelConfig(channel.Config), &messenger.Message{ + Title: msg.Title, + Text: msg.Text, + }) + if err != nil { + return &NotifyResult{Success: false, Error: err.Error()} + } + if !result.Success { + return &NotifyResult{Success: false, Error: result.Error} + } + return &NotifyResult{Success: true} +} + +// SendByChannelID 根据渠道ID发送通知 +func (s *NotificationService) SendByChannelID(channelID string, msg *NotifyMessage) *NotifyResult { + s.mu.RLock() + defer s.mu.RUnlock() + + var notifyWay models.NotifyWay + if err := database.DB.Where("id = ?", channelID).First(¬ifyWay).Error; err != nil { + return &NotifyResult{Success: false, Error: "渠道不存在"} + } + + if !notifyWay.Enabled { + return &NotifyResult{Success: false, Error: "渠道已禁用"} + } + + var config map[string]string + if err := json.Unmarshal([]byte(notifyWay.Config), &config); err != nil { + return &NotifyResult{Success: false, Error: "渠道配置解析失败"} + } + + ch := NotifyChannel{ + ID: notifyWay.ID, + Name: notifyWay.Name, + Type: notifyWay.Type, + Enabled: notifyWay.Enabled, + Config: config, + } + return s.SendToChannel(ch, msg) +} + +// TriggerEvent 触发事件通知(实现 tasks.Notifier 接口) +func (s *NotificationService) TriggerEvent(bindingType string, eventType string, dataID string, templateData map[string]interface{}) { + var title, text string + + switch eventType { + case constant.EventUserLogin: + title = "用户登录通知" + text = fmt.Sprintf("用户 %v 在 IP %v 登录成功", templateData["username"], templateData["ip"]) + case constant.EventBruteForceLogin: + title = "系统安全警告" + text = fmt.Sprintf("检测到 IP %v 正在尝试暴力破解用户 %v", templateData["ip"], templateData["username"]) + case constant.EventPasswordChanged: + title = "账户安全通知" + text = fmt.Sprintf("用户 %v 刚刚修改了密码", templateData["username"]) + case constant.EventTaskSuccess: + title = fmt.Sprintf("任务[%v] 成功", templateData["task_name"]) + text = fmt.Sprintf("任务 #%v %v\n状态: 成功\n耗时: %vms", templateData["task_id"], templateData["task_name"], templateData["duration"]) + case constant.EventTaskFailed: + title = fmt.Sprintf("任务[%v] 失败", templateData["task_name"]) + if errStr, ok := templateData["error"]; ok { + text = fmt.Sprintf("任务 #%v %v\n执行失败\n错误: %v", templateData["task_id"], templateData["task_name"], errStr) + } else { + text = fmt.Sprintf("任务 #%v %v\n执行失败\n状态: %v\n耗时: %vms", templateData["task_id"], templateData["task_name"], templateData["status"], templateData["duration"]) + } + case constant.EventTaskTimeout: + title = fmt.Sprintf("任务[%v] 超时", templateData["task_name"]) + text = fmt.Sprintf("任务 #%v %v\n执行超时\n耗时: %vms", templateData["task_id"], templateData["task_name"], templateData["duration"]) + default: + title = "系统通知" + text = "收到未知事件" + } + + msg := &NotifyMessage{Title: title, Text: text} + + bindings := s.GetBindingsByEvent(bindingType, eventType, dataID) + if len(bindings) == 0 { + return + } + + channels := s.GetChannels() + channelMap := make(map[string]NotifyChannel) + for _, ch := range channels { + channelMap[ch.ID] = ch + } + + for _, binding := range bindings { + ch, ok := channelMap[binding.WayID] + if !ok || !ch.Enabled { + continue + } + go func(channel NotifyChannel) { + result := s.SendToChannel(channel, msg) + if !result.Success { + logger.Warnf("[Notify] 发送事件 %s 到渠道 %s(%s) 失败: %s", eventType, channel.Name, channel.Type, result.Error) + } + }(ch) + } +} + +// --- 内部方法 --- + +// getChannelsInternal 从 notify_ways 表中读取所有渠道配置 +func (s *NotificationService) getChannelsInternal() []NotifyChannel { + var notifyWays []models.NotifyWay + database.DB.Find(¬ifyWays) + + channels := make([]NotifyChannel, 0, len(notifyWays)) + for _, nw := range notifyWays { + var config map[string]string + if err := json.Unmarshal([]byte(nw.Config), &config); err != nil { + logger.Warnf("[Notify] 解析渠道 %s 配置失败: %v", nw.ID, err) + continue + } + channels = append(channels, NotifyChannel{ + ID: nw.ID, + Name: nw.Name, + Type: nw.Type, + Enabled: nw.Enabled, + Config: config, + }) + } + return channels +} diff --git a/internal/services/settings_service.go b/internal/services/settings_service.go index 9ec8cad..b4c8d97 100644 --- a/internal/services/settings_service.go +++ b/internal/services/settings_service.go @@ -89,6 +89,11 @@ func (s *SettingsService) Set(section, key, value string) error { return database.DB.Model(&setting).Update("value", value).Error } +// Delete 删除单个设置 +func (s *SettingsService) Delete(section, key string) error { + return database.DB.Where("section = ? AND `key` = ?", section, key).Delete(&models.Setting{}).Error +} + // GetSection 获取整个 section 的设置 func (s *SettingsService) GetSection(section string) map[string]string { if section == constant.SectionSite { diff --git a/internal/services/tasks/executor_service.go b/internal/services/tasks/executor_service.go index 0d7321d..178432c 100644 --- a/internal/services/tasks/executor_service.go +++ b/internal/services/tasks/executor_service.go @@ -39,6 +39,11 @@ type EnvService interface { GetEnvVarsByIDs(ids string) []string } +// Notifier 通知服务接口定义(避免循环依赖) +type Notifier interface { + TriggerEvent(bindingType string, eventType string, dataID string, templateData map[string]interface{}) +} + // ExecutorService handles task execution and scheduling type ExecutorService struct { taskService *TaskService @@ -46,6 +51,7 @@ type ExecutorService struct { agentWSManager AgentWSManager settingsService SettingsService envService EnvService + notifier Notifier scheduler *executor.Scheduler cronManager *executor.CronManager results []executor.ExecutionResult @@ -65,6 +71,7 @@ func NewExecutorService( agentWSManager AgentWSManager, settingsService SettingsService, envService EnvService, + notifier Notifier, ) *ExecutorService { es := &ExecutorService{ taskService: taskService, @@ -72,6 +79,7 @@ func NewExecutorService( agentWSManager: agentWSManager, settingsService: settingsService, envService: envService, + notifier: notifier, results: make([]executor.ExecutionResult, 0, 100), stopCh: make(chan struct{}), } @@ -246,6 +254,29 @@ func (h *ServerSchedulerHandler) OnTaskCompleted(req *executor.ExecutionRequest, // ======= 重试逻辑 ======= h.es.HandleTaskRetry(task, req, result.Success, result.Status, result.ExitCode) + + // ======= 通知触发 ======= + if h.es.notifier != nil { + go func() { + var eventType string + switch result.Status { + case constant.TaskStatusSuccess: + eventType = constant.EventTaskSuccess + case constant.TaskStatusFailed: + eventType = constant.EventTaskFailed + case constant.TaskStatusTimeout: + eventType = constant.EventTaskTimeout + } + if eventType != "" { + h.es.notifier.TriggerEvent(constant.BindingTypeTask, eventType, task.ID, map[string]interface{}{ + "task_id": task.ID, + "task_name": task.Name, + "status": result.Status, + "duration": result.Duration, + }) + } + }() + } } func (h *ServerSchedulerHandler) OnTaskFailed(req *executor.ExecutionRequest, err error) { @@ -305,6 +336,21 @@ func (h *ServerSchedulerHandler) OnTaskFailed(req *executor.ExecutionRequest, er // ======= 重试逻辑 ======= h.es.HandleTaskRetry(task, req, false, constant.TaskStatusFailed, 1) + + // ======= 通知触发 ======= + if h.es.notifier != nil { + go func() { + taskName := "未知任务" + if task != nil { + taskName = task.Name + } + h.es.notifier.TriggerEvent(constant.BindingTypeTask, constant.EventTaskFailed, taskID, map[string]interface{}{ + "task_id": taskID, + "task_name": taskName, + "error": err.Error(), + }) + }() + } } // HandleTaskRetry 处理任务失败重试逻辑 diff --git a/internal/services/tasks/task_service.go b/internal/services/tasks/task_service.go index 2229fb0..15e49ee 100644 --- a/internal/services/tasks/task_service.go +++ b/internal/services/tasks/task_service.go @@ -124,6 +124,9 @@ func (ts *TaskService) UpdateTask(id string, name, command, schedule string, tim } func (ts *TaskService) DeleteTask(id string) bool { + // 同时删除关联的通知推送设置 + database.DB.Where("type = ? AND data_id = ?", constant.BindingTypeTask, id).Delete(&models.NotifyBinding{}) + result := database.DB.Where("id = ?", id).Delete(&models.Task{}) return result.RowsAffected > 0 } diff --git a/web/src/api/index.ts b/web/src/api/index.ts index ca365aa..7fa1a2d 100644 --- a/web/src/api/index.ts +++ b/web/src/api/index.ts @@ -131,6 +131,9 @@ export const api = { request('/settings/scheduler', { method: 'PUT', body: JSON.stringify(data) }), getPaths: () => request<{ scripts_dir: string }>('/settings/paths'), getAbout: () => request('/settings/about'), + get: (section: string, key: string) => request(`/settings/${section}/${key}`), + generateToken: (section: string, key: string) => + request(`/settings/${section}/${key}/generate`, { method: 'POST' }), getLoginLogs: (params?: { page?: number; page_size?: number; username?: string }) => { const query = new URLSearchParams() if (params?.page) query.set('page', String(params.page)) @@ -261,6 +264,22 @@ export const api = { }, terminal: { cmds: () => request<{ name: string, description: string }[]>('/terminal/cmds') + }, + notify: { + getTypes: () => request<{ channel_types: ChannelType[]; event_types: EventType[] }>('/notify/types'), + getChannels: () => request('/notify/channels'), + saveChannel: (data: Partial) => + request('/notify/channels', { method: 'POST', body: JSON.stringify(data) }), + deleteChannel: (id: string) => request('/notify/channels/' + id, { method: 'DELETE' }), + testChannel: (data: Partial) => + request('/notify/channels/test', { method: 'POST', body: JSON.stringify(data) }), + + getBindings: () => request('/notify/bindings'), + saveBinding: (data: Partial) => + request('/notify/bindings', { method: 'POST', body: JSON.stringify(data) }), + deleteBinding: (id: string) => request('/notify/bindings/' + id, { method: 'DELETE' }), + send: (data: { channel_id: string; title: string; text: string }) => + request('/notify/send', { method: 'POST', body: JSON.stringify(data) }) } } @@ -496,3 +515,39 @@ export interface MiseLanguage { install_path?: string installed_at?: string // 安装日期 } + +export interface ChannelType { + type: string + label: string +} + +export interface EventType { + type: string + label: string + binding_type?: string +} + +export interface NotifyChannel { + id: string + name: string + type: string + enabled: boolean + config: Record +} + +export interface NotifyBinding { + id: string + type: string + event: string + way_id: string + data_id: string + created_at?: string + updated_at?: string +} + +export interface NotifyResult { + success: boolean + error?: string +} + + diff --git a/web/src/assets/index.css b/web/src/assets/index.css index dc8e6ab..6b1bb0a 100644 --- a/web/src/assets/index.css +++ b/web/src/assets/index.css @@ -154,6 +154,10 @@ -moz-osx-font-smoothing: grayscale; text-rendering: optimizeLegibility; } + + .font-code { + font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif; + } } /* 高级感增强 - 微妙的渐变背景 */ diff --git a/web/src/layouts/MainLayout.vue b/web/src/layouts/MainLayout.vue index 5bba3e9..f7639d9 100644 --- a/web/src/layouts/MainLayout.vue +++ b/web/src/layouts/MainLayout.vue @@ -2,7 +2,7 @@ import { ref, onMounted, computed } from 'vue' import { RouterLink, RouterView, useRoute } from 'vue-router' import { resetAuthCache } from '@/router' -import { LayoutDashboard, ListTodo, FileCode, Settings, LogOut, ScrollText, Terminal, Variable, KeyRound, Menu, X, Server, Globe } from 'lucide-vue-next' +import { LayoutDashboard, ListTodo, FileCode, Settings, LogOut, ScrollText, Terminal, Variable, KeyRound, Menu, X, Server, Globe, Bell } from 'lucide-vue-next' import { Button } from '@/components/ui/button' import ThemeToggle from '@/components/ThemeToggle.vue' import { api } from '@/api' @@ -60,6 +60,7 @@ const navItems = [ { to: '/environments', icon: Variable, label: '环境变量', exact: true }, { to: '/languages', icon: Globe, label: '语言依赖', exact: true }, { to: '/terminal', icon: Terminal, label: '终端命令', exact: true }, + { to: '/notify', icon: Bell, label: '消息推送', exact: true }, { to: '/loginlogs', icon: KeyRound, label: '登录日志', exact: true }, { to: '/settings', icon: Settings, label: '系统设置', exact: true }, ] diff --git a/web/src/router/index.ts b/web/src/router/index.ts index a7900cc..59c2ece 100644 --- a/web/src/router/index.ts +++ b/web/src/router/index.ts @@ -47,6 +47,7 @@ const router = createRouter({ { path: 'history', name: 'history', component: () => import('@/views/history/History.vue') }, { path: 'loginlogs', name: 'loginlogs', component: () => import('@/views/loginlogs/LoginLogs.vue') }, { path: 'terminal', name: 'terminal', component: () => import('@/views/terminal/Terminal.vue') }, + { path: 'notify', name: 'notify', component: () => import('@/views/notify/Notify.vue') }, { path: 'settings', name: 'settings', component: () => import('@/views/settings/Settings.vue') } ] } diff --git a/web/src/views/notify/Notify.vue b/web/src/views/notify/Notify.vue new file mode 100644 index 0000000..40a31d1 --- /dev/null +++ b/web/src/views/notify/Notify.vue @@ -0,0 +1,387 @@ + + + diff --git a/web/src/views/notify/components/ApiUsage.vue b/web/src/views/notify/components/ApiUsage.vue new file mode 100644 index 0000000..89c8fd0 --- /dev/null +++ b/web/src/views/notify/components/ApiUsage.vue @@ -0,0 +1,200 @@ + + + diff --git a/web/src/views/notify/components/ChannelDialog.vue b/web/src/views/notify/components/ChannelDialog.vue new file mode 100644 index 0000000..3030659 --- /dev/null +++ b/web/src/views/notify/components/ChannelDialog.vue @@ -0,0 +1,137 @@ + + +