feat: add message push function call

This commit is contained in:
engigu
2026-03-04 21:45:43 +08:00
parent d4663cb492
commit 81b8d2a93d
53 changed files with 4161 additions and 33 deletions
+18 -6
View File
@@ -3,15 +3,22 @@ module github.com/engigu/baihu-panel
go 1.25.0 go 1.25.0
require ( require (
github.com/aliyun/alibaba-cloud-sdk-go v1.63.107
github.com/creack/pty v1.1.24 github.com/creack/pty v1.1.24
github.com/gin-gonic/gin v1.9.1 github.com/gin-gonic/gin v1.9.1
github.com/glebarez/sqlite v1.11.0 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/golang-jwt/jwt/v5 v5.3.0
github.com/gorilla/websocket v1.5.3 github.com/gorilla/websocket v1.5.3
github.com/robfig/cron/v3 v3.0.1 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/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 go.uber.org/zap v1.27.1
golang.org/x/net v0.47.0
golang.org/x/text v0.32.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/ini.v1 v1.67.0
gopkg.in/natefinch/lumberjack.v2 v2.2.1 gopkg.in/natefinch/lumberjack.v2 v2.2.1
gorm.io/driver/mysql v1.6.0 gorm.io/driver/mysql v1.6.0
@@ -26,10 +33,14 @@ require (
github.com/andybalholm/brotli v1.2.0 // indirect github.com/andybalholm/brotli v1.2.0 // indirect
github.com/bep/godartsass/v2 v2.5.0 // indirect github.com/bep/godartsass/v2 v2.5.0 // indirect
github.com/bep/golibsass v1.2.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/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/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/dustin/go-humanize v1.0.1 // indirect
github.com/fatih/color v1.18.0 // 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/fsnotify/fsnotify v1.9.0 // indirect
github.com/gabriel-vasile/mimetype v1.4.2 // indirect github.com/gabriel-vasile/mimetype v1.4.2 // indirect
github.com/gin-contrib/sse v0.1.0 // 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/locales v0.14.1 // indirect
github.com/go-playground/universal-translator v0.18.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-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/go-sql-driver/mysql v1.9.3 // indirect
github.com/gobwas/glob v0.2.3 // indirect github.com/gobwas/glob v0.2.3 // indirect
github.com/goccy/go-json v0.10.2 // 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/gohugoio/hugo v0.149.1 // indirect
github.com/google/uuid v1.6.0 // 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/pgpassfile v1.0.0 // indirect
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
github.com/jackc/pgx/v5 v5.7.6 // indirect github.com/jackc/pgx/v5 v5.7.6 // indirect
github.com/jackc/puddle/v2 v2.2.2 // indirect github.com/jackc/puddle/v2 v2.2.2 // indirect
github.com/jinzhu/inflection v1.0.0 // indirect github.com/jinzhu/inflection v1.0.0 // indirect
github.com/jinzhu/now v1.1.5 // 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/joho/godotenv v1.5.1 // indirect
github.com/json-iterator/go v1.1.12 // indirect github.com/json-iterator/go v1.1.12 // indirect
github.com/klauspost/cpuid/v2 v2.2.4 // 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/mattn/go-isatty v0.0.20 // indirect
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
github.com/modern-go/reflect2 v1.0.2 // 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 v1.9.5 // indirect
github.com/pelletier/go-toml/v2 v2.2.4 // indirect github.com/pelletier/go-toml/v2 v2.2.4 // indirect
github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c // indirect github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // 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/shoenig/go-m1cpu v0.1.6 // indirect
github.com/spf13/afero v1.14.0 // indirect github.com/spf13/afero v1.14.0 // indirect
github.com/spf13/cast v1.9.2 // 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/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/go-sysconf v0.3.12 // indirect
github.com/tklauser/numcpus v0.6.1 // indirect github.com/tklauser/numcpus v0.6.1 // indirect
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
@@ -79,10 +91,10 @@ require (
go.uber.org/multierr v1.10.0 // indirect go.uber.org/multierr v1.10.0 // indirect
golang.org/x/arch v0.3.0 // indirect golang.org/x/arch v0.3.0 // indirect
golang.org/x/crypto v0.46.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/sync v0.19.0 // indirect
golang.org/x/sys v0.39.0 // indirect golang.org/x/sys v0.39.0 // indirect
google.golang.org/protobuf v1.36.8 // 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 gopkg.in/yaml.v3 v3.0.1 // indirect
modernc.org/libc v1.22.5 // indirect modernc.org/libc v1.22.5 // indirect
modernc.org/mathutil v1.5.0 // indirect modernc.org/mathutil v1.5.0 // indirect
+177 -9
View File
@@ -1,13 +1,23 @@
dario.cat/mergo v1.0.2 h1:85+piFYR1tMbRrLcDwR18y4UKJ3aH1Tbzi24VRW1TK8= dario.cat/mergo v1.0.2 h1:85+piFYR1tMbRrLcDwR18y4UKJ3aH1Tbzi24VRW1TK8=
dario.cat/mergo v1.0.2/go.mod h1:E/hbnu0NxMFBjpMIE34DRGLWqDy0g5FuKDhCb31ngxA= 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 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA=
filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4= 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 h1:+tu3HOoMXB7RXEINRVIpxJCT+KdYiI7LAEAUrOw3dIU=
github.com/BurntSushi/locker v0.0.0-20171006230638-a6e239ea1c69/go.mod h1:L1AbZdiDllfyYH5l5OkAaZtk7VkWe89bPJFmnDBNHxg= 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 h1:+gs/NgTzYYe+gGPyfHy3XxpJReQWC1pIsiKIg0LgNt4=
github.com/air-verse/air v1.64.5/go.mod h1:OaJZSfZqf7wyjS2oP/CcEVyIt0JmZuPh5x1gdtklmmY= 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 h1:sfIHpxPyR07/Oylvmcai3X/exDlE8+FA820NTz+9sGw=
github.com/alecthomas/chroma/v2 v2.20.0/go.mod h1:e7tViK0xh/Nf4BYHl00ycY6rV7b8iXBksI9E359yNmA= 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 h1:ukwgCxwYrmACq68yiUqwIWnGY0cTPox/M94sVwToPjQ=
github.com/andybalholm/brotli v1.2.0/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY= 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= 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/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 h1:CsQnSC6MsomH64gw0cT5f+EwQDcvZz4AazKunFwTpuI=
github.com/bep/tmc v0.5.1/go.mod h1:tGYHN8fS85aJPhDLgXETVKp+PR382OvFi2+q2GkGsq0= 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.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 h1:6iJ6NqdoxCDr6mbY8h18oSO+cShGSMRGCEo7F2h0x8s=
github.com/bytedance/sonic v1.9.1/go.mod h1:i736AoUSYt75HyZLoJW9ERYxcy6eaN6h4BZXU064P/U= 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 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= 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-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 h1:qSGYFH7+jGhDF8vLC+iwCD4WpbV1EBDSzWkJODFLams=
github.com/chenzhuoyu/base64x v0.0.0-20221115062448-fe3a3abad311/go.mod h1:b583jCggY9gE99b6G5LEC39OIiVsWj+R97kbl5odCEk= 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 h1:WA/La7UGCanFe5NpHF0Q3DNtnCsVoxbPKuyBNHWRyME=
github.com/clbanning/mxj/v2 v2.7.0/go.mod h1:hNiWqW14h+kc+MdF9C6/YoRfjEJoR3ou6tn/Qo+ve2s= 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 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s=
github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE= 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.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 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 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 h1:Y005a1X4Z7Uc+0gLpSAsKhWi4qLtsdEcMIbbdvdZ6pc=
github.com/disintegration/gift v1.2.1/go.mod h1:Jh2i7f7Q2BM7Ezno3PhfezbR1xpUg9dUg3/RlKGr4HI= github.com/disintegration/gift v1.2.1/go.mod h1:Jh2i7f7Q2BM7Ezno3PhfezbR1xpUg9dUg3/RlKGr4HI=
github.com/dlclark/regexp2 v1.11.5 h1:Q/sSnsKerHeCkc/jSTNq1oCm7KiVgUMZRDUoRu0JQZQ= 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/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 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM=
github.com/fatih/color v1.18.0/go.mod h1:4FelSpRwEGDpQ12mAdzqdOukCy4u8WUtOY6lkT/6HfU= 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.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 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8=
github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= 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 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k=
github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0=
github.com/gabriel-vasile/mimetype v1.4.2 h1:w5qFW6JKBz9Y393Y4q372O9A7cUSequkh1Q7OhCmWKU= 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/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 h1:wSG0irqzP6VurnMEpFGer5Li19RpIRi2qvQz++w0GMw=
github.com/glebarez/sqlite v1.11.0/go.mod h1:h8/o8j5wiAsqSPoWELDUdJXhjAhsVliSn7bWZjOhrgQ= 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 h1:/Fpf6oFPoeFik9ty7siob0G6Ke8QvQEuVcuChpwXzpY=
github.com/go-ole/go-ole v1.2.6/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0= 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= 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/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 h1:vgvQWe3XCz3gIeFDm/HnTIbj6UGmg/+t63MyGU2n5js=
github.com/go-playground/validator/v10 v10.14.0/go.mod h1:9iXMNT7sEkjXb0I+enO7QXmzG6QCsPWY4zveKFVRSyU= 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 h1:U/N249h2WzJ3Ukj8SowVFjdtZKfu9vlLZxjPXV1aweo=
github.com/go-sql-driver/mysql v1.9.3/go.mod h1:qn46aNg1333BRMNU69Lq93t8du/dwxI64Gl8i5p1WMU= 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 h1:xeWBM2nui+qnVvNM4S3foBhCAL2XgPU+a7FdpelbTq4=
github.com/gobuffalo/flect v1.0.3/go.mod h1:A5msMlrHtLqh9umBSnvabjsMrCcCpAyzglnDvkbYKHs= github.com/gobuffalo/flect v1.0.3/go.mod h1:A5msMlrHtLqh9umBSnvabjsMrCcCpAyzglnDvkbYKHs=
github.com/gobwas/glob v0.2.3 h1:A4xDbljILXROh+kObIiy5kIaPYD8e96x1tgBhUI5J+Y= 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/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 h1:KTYMi8fCWYLswFyJAeOtuk/EkXR/KPTHHNN9OS+RTxo=
github.com/gohugoio/localescompressed v1.0.1/go.mod h1:jBF6q8D7a0vaEmcWPNcAjUZLJaIVNiwvM3WlmTvooB0= 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 h1:pv4AsKCKKZuqlgs5sUmn4x8UlGa0kEVt/puTpKx9vvo=
github.com/golang-jwt/jwt/v5 v5.3.0/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= 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.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.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 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= 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/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 h1:Xim43kblpZXfIBQsbuBVKCudVG457BR2GZFIz3uw3hQ=
github.com/google/pprof v0.0.0-20221118152302-e6195bd50e26/go.mod h1:dDKJzRmX4S37WGHujM7tX//fmj1uioxKzKxz3lo4HJo= 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= 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/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 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= 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 h1:s0W4wF8bdsBEjTWzwzSlsatSthWtTAF2xLgo4a4RwAo=
github.com/hairyhenderson/go-codeowners v0.7.0/go.mod h1:wUlNgQ3QjqC4z8DnM5nnCYVq/icpqXJyJOukKx5U8/Q= 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 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM= 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/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU=
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= 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 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo= 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/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 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ=
github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8= 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 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= 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 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY=
github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y= 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 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= 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.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 h1:acbojRNwl3o09bUq+yDCtZFc1aiwaAAxtcn8YkZXnvk=
github.com/klauspost/cpuid/v2 v2.2.4/go.mod h1:RVVoqg1df56z8g3pUjL/3lE5UfnlrJX8tyFgg4nqhuY= 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/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 h1:JTlSkmxWg/oQ1TcLDoypuirdE8Y/jzNirQeLkxpA6Oc=
github.com/muesli/smartcrop v0.3.0/go.mod h1:i2fCI/UorTfgEpPPLWiFBv4pye+YAG78RwcQLUkocpI= 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 h1:/3s4uTPOF06pImGa2Yvlp24yKXZoTYM+nsIlMzfpg/0=
github.com/niklasfasching/go-org v1.9.1/go.mod h1:ZAGFFkWvUQcpazmi/8nHqwvARpr1xpb+Es67oUGX/48= 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 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/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= 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/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 h1:XGwRsYLC2bY7bNd93Dk51bcPZksWZmLYuaTHR0FqfL8=
github.com/olekukonko/tablewriter v1.0.9/go.mod h1:5c+EBPeSqvXnLLgkm9isDdzR3wjfBkHR9Nhfp3NWrzo= 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 h1:onHthvaw9LFnH4t2DcNVpwGmV9E1BkGknEliJkfwQj0=
github.com/pbnjay/memory v0.0.0-20210728143218-7b4eea64cf58/go.mod h1:DXv8WO4yhMYhSNPKjeNKa5WY9YCIEBRbNzFFPJbWO6Y= github.com/pbnjay/memory v0.0.0-20210728143218-7b4eea64cf58/go.mod h1:DXv8WO4yhMYhSNPKjeNKa5WY9YCIEBRbNzFFPJbWO6Y=
github.com/pelletier/go-toml v1.9.5 h1:4yBQzkHv+7BHq2PQUZF3Mx0IYxG7LsP222s7Agd3ve8= 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/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 h1:fV591PaemRlL6JfRxGDEPl69wICngIQ3shQtzfy2gxU=
github.com/rs/xid v1.6.0/go.mod h1:7XoLgs4eV+QndskICGsho+ADou8ySMSjJKDIan90Nz0= 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 h1:i0t8kL+kQTvpAYToeuiVk3TgDeKOFioZO3Ztz/iZ9pI=
github.com/shirou/gopsutil/v3 v3.24.5/go.mod h1:bsoOS1aStSs9ErQ1WWfxllSeS1K5D+U30r2NfcubMVk= 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 h1:nxdKQNcEB6vzgA2E2bvzKIYRuNj7XNJ4S/aRSwKzFtM=
github.com/shoenig/go-m1cpu v0.1.6/go.mod h1:1JJMcUBvfNwpq05QDQVAnx3gUHr9IYF7GNg9SUEw2VQ= 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 h1:kVTaSd7WLz5WZ2IaoM0RSzRsUD+m8wRR+5qvntpn4LU=
github.com/shoenig/test v0.6.4/go.mod h1:byHiCGXqrVaflBLAMq/srcZIHynQPQgeyvkvXnjqq0k= 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 h1:9tH6MapGnn/j0eb0yIXiLjERO8RB6xIVZRDCX7PtqWA=
github.com/spf13/afero v1.14.0/go.mod h1:acJQ8t0ohCGuMN3O+Pv0V0hgMxNYDlvdk+VTfyZmbYo= 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 h1:SsGfm7M8QOFtEzumm7UZrZdLLquNdzFYfIbEXntcFbE=
github.com/spf13/cast v1.9.2/go.mod h1:jNfB8QC9IA6ZuY2ZjDp0KtFO2LZZlg4S/7bzP6qqeHo= 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.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.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= 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.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.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.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= 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/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 h1:IcZ56OuxrtaEz8UYNRHBrUa9bYeX9oVY93KspZZBf/I=
github.com/tetratelabs/wazero v1.9.0/go.mod h1:TSbcXCfFP0L2FGkRPxHphadXPjo1T6W+CseNNY7EkjM= 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 h1:0QaGUFOdQaIVdPgfITYzaTegZvdCjmYO52cSFAEVmqU=
github.com/tklauser/go-sysconf v0.3.12/go.mod h1:Ho14jnntGE1fpdOqQEEaiKRpvIavV0hSfmBq8nJbHYI= 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 h1:ng9scYS7az0Bk4OZLvrNXNSAO2Pxr1XXRAPyjhIx+Fk=
github.com/tklauser/numcpus v0.6.1/go.mod h1:1XfjsgE2zo8GVw7POkMbHENHzVg3GzmoZ9fESEdAacY= 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 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08= 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 h1:BMaWp1Bb6fHwEtbplGBGJ498wD+LKlNSl25MjdZY4dU=
github.com/ugorji/go/codec v1.2.11/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg= 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 h1:8pffMNWIlC0O5vbyHWFZAt5yWvWcrHA+3ovIIjVWss0=
github.com/woodsbury/decimal128 v1.3.0/go.mod h1:C5UTmyTjW3JftjUFzOVhC20BEQa2a4ZKOB5I6Zjb+ds= 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 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU=
github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E= 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 h1:GPddIs617DnBLFFVJFgpo1aBfe/4xcvMc3SB5t/D0pA=
github.com/yuin/goldmark v1.7.13/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg= 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 h1:QWfF2FYaXwL74tfGOW5izeiZepUDroDJfWubQI9HTHs=
github.com/yuin/goldmark-emoji v1.0.6/go.mod h1:ukxJDKFpdFb5x0a5HqbdlcKtebh086iJpI31LTKmWuA= 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 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0=
github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0= 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 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= 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 h1:S0h4aNzvfcFsC3dRF1jLoaov7oRaKqRGC/pUEJ2yvPQ=
go.uber.org/multierr v1.10.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= 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 h1:08RqriUEv8+ArZRYSTXy1LeBScaMpVSTBhCeaZYfMYc=
go.uber.org/zap v1.27.1/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E= 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.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 h1:02VY4/ZcO/gBOH6PUaoiptASxtXU10jazRCP865E97k=
golang.org/x/arch v0.3.0/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8= 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 h1:cKRW/pmt1pKAfetfu+RCEvjvZkA9RimPbh7bhFjGVBU=
golang.org/x/crypto v0.46.0/go.mod h1:Evb/oLKmMraqjZ2iQTwDwvCtJkczlDuTmdJXoZVzqU0= 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 h1:DXr+pvt3nC887026GRP39Ej11UATqWDmWuS99x26cD0=
golang.org/x/exp v0.0.0-20250819193227-8b4c13bb791b/go.mod h1:4QTo5u+SEIbbKW1RacMZq1YEfOBqeXa19JeshGi+zc4= 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 h1:jD5RhkmVAnjqaCUXfbGBrn3lpxbknfN9w2UhHHU+5B4=
golang.org/x/image v0.30.0/go.mod h1:SAEUTxCCMWSrJcCy/4HwavEsfZZJlYxeHLc6tTiAe/c= 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 h1:fDEXFVZ/fmCKProc/yAXXUijritrDzahmwwefnjoPFk=
golang.org/x/mod v0.30.0/go.mod h1:lAsf5O2EvJeSFMiBxXDki7sCgAxEUcZHXoXMKT4GJKc= 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 h1:Mx+4dIFzqraBXUugkia1OOvlD6LemFo1ALMHjrXDOhY=
golang.org/x/net v0.47.0/go.mod h1:/jNxtkgq5yWUGYkaZGqo27cfGZ1c5Nen03aYrrKpVRU= 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 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= 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-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-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-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.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.8.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.11.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.39.0 h1:CvCKL8MeisomCi6qNZ+wbb0DN9E5AATixKsvNtMoMFk= 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/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 h1:ZD01bjUt1FQ9WJ0ClOL5vxgxOI/sVCNgX1YtKwcY0mU=
golang.org/x/text v0.32.0/go.mod h1:o/rUWzghvpD5TXrTIBuJU77MTaN0ljMWE47kxGJQ7jY= 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 h1:ik4ho21kwuQln40uelmciQPp9SipgNDdrafrYA4TmQQ=
golang.org/x/tools v0.39.0/go.mod h1:JnefbkDPyD8UU2kI5fuf8ZX4/yUeh9W877ZeBONxUqQ= 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-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 h1:xHScyCOEuuwZEc6UtSOvPbAT4zRh0xcNRYekJwfqyMc=
google.golang.org/protobuf v1.36.8/go.mod h1:fuxRtAxBytpl4zzqUh6/eyUujkJdNiuEkXntxiD/uRU= 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 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 h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= 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 h1:Dgnx+6+nfE+IfzjUEISNeydPJh9AXNNsWbGP9KzCsOA=
gopkg.in/ini.v1 v1.67.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k= 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 h1:bBRl1b0OH9s/DuPhuXpNl+VtCaJXFZ5/uEFST95x9zc=
gopkg.in/natefinch/lumberjack.v2 v2.2.1/go.mod h1:YD8tP3GAjkrDg1eZH7EGmyESg/lsYskCTPBJVb9jqSc= 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 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= 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-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 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gorm.io/driver/mysql v1.6.0 h1:eNbLmNTpPpTOVZi8MMxCi2aaIm0ZpInbORNXDwyLGvg= gorm.io/driver/mysql v1.6.0 h1:eNbLmNTpPpTOVZi8MMxCi2aaIm0ZpInbORNXDwyLGvg=
+20
View File
@@ -34,6 +34,7 @@ const (
SectionSystem = "system" SectionSystem = "system"
SectionScheduler = "scheduler" SectionScheduler = "scheduler"
SectionSecurity = "security" SectionSecurity = "security"
SectionNotify = "notify"
// Site Settings Key 常量 // Site Settings Key 常量
KeyTitle = "title" KeyTitle = "title"
@@ -54,6 +55,25 @@ const (
KeyQueueSize = "queue_size" KeyQueueSize = "queue_size"
KeyRateInterval = "rate_interval" 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 消息类型 // WebSocket 消息类型
WSTypeHeartbeat = "heartbeat" WSTypeHeartbeat = "heartbeat"
WSTypeHeartbeatAck = "heartbeat_ack" WSTypeHeartbeatAck = "heartbeat_ack"
+10
View File
@@ -1,6 +1,7 @@
package controllers package controllers
import ( import (
"strconv" "strconv"
"sync" "sync"
"time" "time"
@@ -54,6 +55,10 @@ func (ac *AuthController) Login(c *gin.Context) {
attempt := val.(*loginAttempt) attempt := val.(*loginAttempt)
if attempt.Count >= 5 && time.Since(attempt.LastAttempt) < time.Minute { if attempt.Count >= 5 && time.Since(attempt.LastAttempt) < time.Minute {
ac.loginLogService.Create(req.Username, ip, userAgent, "failed", "尝试次数过多,请一分钟后再试") 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, "尝试次数过多,请一分钟后再试") utils.TooManyRequests(c, "尝试次数过多,请一分钟后再试")
return return
} }
@@ -102,6 +107,11 @@ func (ac *AuthController) Login(c *gin.Context) {
// 记录登录成功日志 // 记录登录成功日志
ac.loginLogService.Create(req.Username, ip, userAgent, "success", "登录成功") 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{ utils.Success(c, gin.H{
"user": user.Username, "user": user.Username,
}) })
@@ -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)
}
@@ -76,6 +76,10 @@ func (sc *SettingsController) ChangePassword(c *gin.Context) {
return return
} }
go services.NewNotificationService().TriggerEvent(constant.BindingTypeSystem, constant.EventPasswordChanged, "", map[string]interface{}{
"username": user.Username,
})
utils.SuccessMsg(c, "密码修改成功") utils.SuccessMsg(c, "密码修改成功")
} }
@@ -373,3 +377,39 @@ func (sc *SettingsController) RestoreBackup(c *gin.Context) {
utils.SuccessMsg(c, "恢复成功") 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)
}
+2
View File
@@ -24,6 +24,8 @@ func Migrate() error {
&models.Agent{}, &models.Agent{},
&models.AgentToken{}, &models.AgentToken{},
&models.Language{}, &models.Language{},
&models.NotifyWay{},
&models.NotifyBinding{},
) )
} }
+38
View File
@@ -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()
}
}
+23
View File
@@ -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"
}
+23
View File
@@ -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"
}
+16 -15
View File
@@ -30,7 +30,7 @@ func RegisterControllers() *Controllers {
// 清理 task 运行状态的任务可以直接由 executorService 承担或在此处通过 Database 直接清理 // 清理 task 运行状态的任务可以直接由 executorService 承担或在此处通过 Database 直接清理
// 简单期间,我们使用一个新方法 tasks.CleanupRunningTasks() 或者让 executorService 启动时清理 // 简单期间,我们使用一个新方法 tasks.CleanupRunningTasks() 或者让 executorService 启动时清理
executorService = tasks.NewExecutorService(taskService, taskLogService, agentWSManager, settingsService, envService) executorService = tasks.NewExecutorService(taskService, taskLogService, agentWSManager, settingsService, envService, services.NewNotificationService())
// 启动时清理残留的运行状态 // 启动时清理残留的运行状态
_ = executorService.CleanupRunningTasks() _ = executorService.CleanupRunningTasks()
@@ -39,20 +39,21 @@ func RegisterControllers() *Controllers {
// 初始化并返回控制器 // 初始化并返回控制器
return &Controllers{ return &Controllers{
Task: controllers.NewTaskController(taskService, executorService), Task: controllers.NewTaskController(taskService, executorService),
Auth: controllers.NewAuthController(userService, settingsService, loginLogService), Auth: controllers.NewAuthController(userService, settingsService, loginLogService),
Env: controllers.NewEnvController(envService), Env: controllers.NewEnvController(envService),
Script: controllers.NewScriptController(scriptService), Script: controllers.NewScriptController(scriptService),
Executor: controllers.NewExecutorController(executorService), Executor: controllers.NewExecutorController(executorService),
File: controllers.NewFileController(constant.ScriptsWorkDir), File: controllers.NewFileController(constant.ScriptsWorkDir),
Dashboard: controllers.NewDashboardController(executorService), Dashboard: controllers.NewDashboardController(executorService),
Log: controllers.NewLogController(), Log: controllers.NewLogController(),
LogWS: controllers.NewLogWSController(), LogWS: controllers.NewLogWSController(),
Terminal: controllers.NewTerminalController(envService), Terminal: controllers.NewTerminalController(envService),
Settings: controllers.NewSettingsController(userService, loginLogService, executorService), Settings: controllers.NewSettingsController(userService, loginLogService, executorService),
Dependency: controllers.NewDependencyController(), Dependency: controllers.NewDependencyController(),
Agent: controllers.NewAgentController(settingsService), Agent: controllers.NewAgentController(settingsService),
Mise: controllers.NewMiseController(services.NewMiseService()), Mise: controllers.NewMiseController(services.NewMiseService()),
Notification: controllers.NewNotificationController(),
} }
} }
+26 -2
View File
@@ -26,8 +26,9 @@ type Controllers struct {
Terminal *controllers.TerminalController Terminal *controllers.TerminalController
Settings *controllers.SettingsController Settings *controllers.SettingsController
Dependency *controllers.DependencyController Dependency *controllers.DependencyController
Agent *controllers.AgentController Agent *controllers.AgentController
Mise *controllers.MiseController Mise *controllers.MiseController
Notification *controllers.NotificationController
} }
func mustSubFS(fsys fs.FS, dir string) fs.FS { 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/status", c.Settings.GetBackupStatus)
settings.GET("/backup/download", c.Settings.DownloadBackup) settings.GET("/backup/download", c.Settings.DownloadBackup)
settings.POST("/restore", c.Settings.RestoreBackup) settings.POST("/restore", c.Settings.RestoreBackup)
// 通用设置接口
settings.GET("/:section/:key", c.Settings.GetSetting)
settings.POST("/:section/:key/generate", c.Settings.GenerateSettingToken)
} }
// Dependency routes (依赖管理) // Dependency routes (依赖管理)
@@ -246,6 +250,26 @@ func Setup(c *Controllers) *gin.Engine {
{ {
agentAPIv1.GET("/download", c.Agent.Download) 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)
} }
} }
+140
View File
@@ -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...)
}
+36
View File
@@ -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
}
+133
View File
@@ -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&timestamp=%d&sign=%s", wh, timestamp, sign)
return url
}
+46
View File
@@ -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)
}
+133
View File
@@ -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 += "<at user_id=\"all\">所有人</at>"
} else {
atContent += fmt.Sprintf("<at user_id=\"%s\"></at>", 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
}
+67
View File
@@ -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
}
+76
View File
@@ -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
}
+51
View File
@@ -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))
}
+111
View File
@@ -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
}
+181
View File
@@ -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
}
+63
View File
@@ -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
}
+161
View File
@@ -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{}
})
```
@@ -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
}
+34
View File
@@ -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
}
@@ -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...)}
}
+35
View File
@@ -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
}
+41
View File
@@ -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
}
+49
View File
@@ -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
}
+43
View File
@@ -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
}
+34
View File
@@ -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
}
@@ -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
}
+33
View File
@@ -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
}
+29
View File
@@ -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
}
@@ -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
}
@@ -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
}
+75
View File
@@ -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"
)
+38
View File
@@ -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
}
+195
View File
@@ -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
}
+323
View File
@@ -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(&notifyWay).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(&notifyWays)
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
}
+5
View File
@@ -89,6 +89,11 @@ func (s *SettingsService) Set(section, key, value string) error {
return database.DB.Model(&setting).Update("value", value).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 的设置 // GetSection 获取整个 section 的设置
func (s *SettingsService) GetSection(section string) map[string]string { func (s *SettingsService) GetSection(section string) map[string]string {
if section == constant.SectionSite { if section == constant.SectionSite {
@@ -39,6 +39,11 @@ type EnvService interface {
GetEnvVarsByIDs(ids string) []string 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 // ExecutorService handles task execution and scheduling
type ExecutorService struct { type ExecutorService struct {
taskService *TaskService taskService *TaskService
@@ -46,6 +51,7 @@ type ExecutorService struct {
agentWSManager AgentWSManager agentWSManager AgentWSManager
settingsService SettingsService settingsService SettingsService
envService EnvService envService EnvService
notifier Notifier
scheduler *executor.Scheduler scheduler *executor.Scheduler
cronManager *executor.CronManager cronManager *executor.CronManager
results []executor.ExecutionResult results []executor.ExecutionResult
@@ -65,6 +71,7 @@ func NewExecutorService(
agentWSManager AgentWSManager, agentWSManager AgentWSManager,
settingsService SettingsService, settingsService SettingsService,
envService EnvService, envService EnvService,
notifier Notifier,
) *ExecutorService { ) *ExecutorService {
es := &ExecutorService{ es := &ExecutorService{
taskService: taskService, taskService: taskService,
@@ -72,6 +79,7 @@ func NewExecutorService(
agentWSManager: agentWSManager, agentWSManager: agentWSManager,
settingsService: settingsService, settingsService: settingsService,
envService: envService, envService: envService,
notifier: notifier,
results: make([]executor.ExecutionResult, 0, 100), results: make([]executor.ExecutionResult, 0, 100),
stopCh: make(chan struct{}), 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) 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) { 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) 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 处理任务失败重试逻辑 // HandleTaskRetry 处理任务失败重试逻辑
+3
View File
@@ -124,6 +124,9 @@ func (ts *TaskService) UpdateTask(id string, name, command, schedule string, tim
} }
func (ts *TaskService) DeleteTask(id string) bool { 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{}) result := database.DB.Where("id = ?", id).Delete(&models.Task{})
return result.RowsAffected > 0 return result.RowsAffected > 0
} }
+55
View File
@@ -131,6 +131,9 @@ export const api = {
request('/settings/scheduler', { method: 'PUT', body: JSON.stringify(data) }), request('/settings/scheduler', { method: 'PUT', body: JSON.stringify(data) }),
getPaths: () => request<{ scripts_dir: string }>('/settings/paths'), getPaths: () => request<{ scripts_dir: string }>('/settings/paths'),
getAbout: () => request<AboutInfo>('/settings/about'), getAbout: () => request<AboutInfo>('/settings/about'),
get: (section: string, key: string) => request<string>(`/settings/${section}/${key}`),
generateToken: (section: string, key: string) =>
request<string>(`/settings/${section}/${key}/generate`, { method: 'POST' }),
getLoginLogs: (params?: { page?: number; page_size?: number; username?: string }) => { getLoginLogs: (params?: { page?: number; page_size?: number; username?: string }) => {
const query = new URLSearchParams() const query = new URLSearchParams()
if (params?.page) query.set('page', String(params.page)) if (params?.page) query.set('page', String(params.page))
@@ -261,6 +264,22 @@ export const api = {
}, },
terminal: { terminal: {
cmds: () => request<{ name: string, description: string }[]>('/terminal/cmds') cmds: () => request<{ name: string, description: string }[]>('/terminal/cmds')
},
notify: {
getTypes: () => request<{ channel_types: ChannelType[]; event_types: EventType[] }>('/notify/types'),
getChannels: () => request<NotifyChannel[]>('/notify/channels'),
saveChannel: (data: Partial<NotifyChannel>) =>
request('/notify/channels', { method: 'POST', body: JSON.stringify(data) }),
deleteChannel: (id: string) => request('/notify/channels/' + id, { method: 'DELETE' }),
testChannel: (data: Partial<NotifyChannel>) =>
request<NotifyResult>('/notify/channels/test', { method: 'POST', body: JSON.stringify(data) }),
getBindings: () => request<NotifyBinding[]>('/notify/bindings'),
saveBinding: (data: Partial<NotifyBinding>) =>
request<NotifyBinding>('/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<NotifyResult>('/notify/send', { method: 'POST', body: JSON.stringify(data) })
} }
} }
@@ -496,3 +515,39 @@ export interface MiseLanguage {
install_path?: string install_path?: string
installed_at?: 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<string, string>
}
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
}
+4
View File
@@ -154,6 +154,10 @@
-moz-osx-font-smoothing: grayscale; -moz-osx-font-smoothing: grayscale;
text-rendering: optimizeLegibility; 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;
}
} }
/* 高级感增强 - 微妙的渐变背景 */ /* 高级感增强 - 微妙的渐变背景 */
+2 -1
View File
@@ -2,7 +2,7 @@
import { ref, onMounted, computed } from 'vue' import { ref, onMounted, computed } from 'vue'
import { RouterLink, RouterView, useRoute } from 'vue-router' import { RouterLink, RouterView, useRoute } from 'vue-router'
import { resetAuthCache } from '@/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 { Button } from '@/components/ui/button'
import ThemeToggle from '@/components/ThemeToggle.vue' import ThemeToggle from '@/components/ThemeToggle.vue'
import { api } from '@/api' import { api } from '@/api'
@@ -60,6 +60,7 @@ const navItems = [
{ to: '/environments', icon: Variable, label: '环境变量', exact: true }, { to: '/environments', icon: Variable, label: '环境变量', exact: true },
{ to: '/languages', icon: Globe, label: '语言依赖', exact: true }, { to: '/languages', icon: Globe, label: '语言依赖', exact: true },
{ to: '/terminal', icon: Terminal, 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: '/loginlogs', icon: KeyRound, label: '登录日志', exact: true },
{ to: '/settings', icon: Settings, label: '系统设置', exact: true }, { to: '/settings', icon: Settings, label: '系统设置', exact: true },
] ]
+1
View File
@@ -47,6 +47,7 @@ const router = createRouter({
{ path: 'history', name: 'history', component: () => import('@/views/history/History.vue') }, { path: 'history', name: 'history', component: () => import('@/views/history/History.vue') },
{ path: 'loginlogs', name: 'loginlogs', component: () => import('@/views/loginlogs/LoginLogs.vue') }, { path: 'loginlogs', name: 'loginlogs', component: () => import('@/views/loginlogs/LoginLogs.vue') },
{ path: 'terminal', name: 'terminal', component: () => import('@/views/terminal/Terminal.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') } { path: 'settings', name: 'settings', component: () => import('@/views/settings/Settings.vue') }
] ]
} }
+387
View File
@@ -0,0 +1,387 @@
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from '@/components/ui/alert-dialog'
import { api, type NotifyChannel, type ChannelType, type EventType, type NotifyBinding } from '@/api'
import { toast } from 'vue-sonner'
import ChannelList from './components/ChannelList.vue'
import EventBinding from './components/EventBinding.vue'
import ApiUsage from './components/ApiUsage.vue'
import ChannelDialog from './components/ChannelDialog.vue'
const activeTab = ref('channels')
// 渠道数据
const channels = ref<NotifyChannel[]>([])
const channelTypes = ref<ChannelType[]>([])
const eventTypes = ref<EventType[]>([])
const loading = ref(false)
// API Token
const apiToken = ref('')
// 编辑弹窗
const showDialog = ref(false)
const editingChannel = ref<Partial<NotifyChannel>>({
name: '',
type: '',
enabled: true,
config: {}
})
const isEditing = ref(false)
// 删除确认
const showDeleteConfirm = ref(false)
const deletingChannelId = ref('')
// 事件绑定
const bindings = ref<NotifyBinding[]>([])
const allTasks = ref<Task[]>([])
// 渠道配置模板
const channelConfigFields: Record<string, { key: string; label: string; required: boolean; placeholder?: string; type?: string }[]> = {
Telegram: [
{ key: 'bot_token', label: 'Bot Token', required: true, placeholder: '从 @BotFather 获取' },
{ key: 'chat_id', label: 'Chat ID', required: true, placeholder: '聊天/群组 ID' },
{ key: 'api_host', label: 'API 地址', required: false, placeholder: '自定义 API 地址,留空使用官方' },
{ key: 'proxy_url', label: '代理地址', required: false, placeholder: 'http/https/socks5 代理' },
],
Bark: [
{ key: 'push_key', label: 'Push Key', required: true, placeholder: 'Bark Push Key' },
{ key: 'sound', label: '推送声音', required: false, placeholder: '留空使用默认' },
{ key: 'group', label: '推送分组', required: false },
{ key: 'icon', label: '推送图标', required: false, placeholder: '图标 URL' },
{ key: 'level', label: '时效性', required: false, placeholder: 'active / timeSensitive / passive' },
{ key: 'url', label: '跳转URL', required: false },
],
Dtalk: [
{ key: 'access_token', label: 'Access Token', required: true, placeholder: '钉钉机器人 access_token' },
{ key: 'secret', label: '加签秘钥', required: false, placeholder: '可选' },
],
QyWeiXin: [
{ key: 'access_token', label: 'Access Token', required: true, placeholder: '企业微信机器人 Key' },
],
Feishu: [
{ key: 'access_token', label: 'Access Token', required: true, placeholder: '飞书机器人 access_token' },
{ key: 'secret', label: '加签秘钥', required: false, placeholder: '可选' },
],
Custom: [
{ key: 'webhook', label: 'Webhook URL', required: true, placeholder: 'https://...' },
{ key: 'body', label: '请求体模板', required: false, placeholder: '使用 TEXT 作为消息内容占位符', type: 'textarea' },
],
Ntfy: [
{ key: 'topic', label: 'Topic', required: true },
{ key: 'url', label: 'API 地址', required: false, placeholder: '默认 https://ntfy.sh' },
{ key: 'priority', label: '优先级', required: false, placeholder: '1-5' },
{ key: 'icon', label: '图标 URL', required: false },
{ key: 'token', label: 'Token', required: false },
{ key: 'username', label: '用户名', required: false },
{ key: 'password', label: '密码', required: false },
],
Gotify: [
{ key: 'url', label: '服务地址', required: true, placeholder: 'https://gotify.example.com' },
{ key: 'token', label: 'Token', required: true },
{ key: 'priority', label: '优先级', required: false, placeholder: '0-10' },
],
PushMe: [
{ key: 'push_key', label: 'Push Key', required: true },
{ key: 'url', label: 'API 地址', required: false, placeholder: '默认 https://push.i-i.me' },
{ key: 'type', label: '类型', required: false },
],
Email: [
{ key: 'server', label: 'SMTP 服务器', required: true, placeholder: 'smtp.example.com' },
{ key: 'port', label: '端口', required: true, placeholder: '465' },
{ key: 'account', label: '邮箱账号', required: true },
{ key: 'passwd', label: '邮箱密码', required: true },
{ key: 'from_name', label: '发信人名称', required: false },
{ key: 'to_account', label: '收件邮箱', required: true },
],
AliyunSMS: [
{ key: 'access_key_id', label: 'AccessKeyId', required: true },
{ key: 'access_key_secret', label: 'AccessKeySecret', required: true },
{ key: 'sign_name', label: '短信签名', required: true },
{ key: 'region_id', label: '区域ID', required: false, placeholder: '默认 cn-hangzhou' },
{ key: 'phone_number', label: '手机号码', required: true },
{ key: 'template_code', label: '短信模板 CODE', required: true },
],
}
// 加载数据
async function loadData() {
loading.value = true
try {
const [typesRes, channelsRes, tasksRes] = await Promise.all([
api.notify.getTypes(),
api.notify.getChannels(),
api.tasks.list({ page: 1, page_size: 1000 })
])
channelTypes.value = typesRes.channel_types
eventTypes.value = typesRes.event_types
channels.value = channelsRes
allTasks.value = tasksRes.data
} catch (e: any) {
toast.error('加载失败: ' + e.message)
} finally {
loading.value = false
}
}
async function loadEvents() {
try {
bindings.value = await api.notify.getBindings()
} catch (e: any) {
toast.error('加载事件绑定失败: ' + e.message)
}
}
// 渠道操作
function openNewChannel() {
editingChannel.value = { name: '', type: '', enabled: true, config: {} }
isEditing.value = false
showDialog.value = true
}
function openEditChannel(ch: NotifyChannel) {
editingChannel.value = { ...ch, config: { ...ch.config } }
isEditing.value = true
showDialog.value = true
}
function onTypeChange(val: string) {
editingChannel.value.type = val
const existing = editingChannel.value.config || {}
const fields = channelConfigFields[val] || []
const newConfig: Record<string, string> = {}
for (const f of fields) {
newConfig[f.key] = existing[f.key] || ''
}
editingChannel.value.config = newConfig
}
async function saveChannel() {
if (!editingChannel.value.name || !editingChannel.value.type) {
toast.error('请填写渠道名称和类型')
return
}
// 确保 enabled 字段有值
const channelData = {
...editingChannel.value,
enabled: editingChannel.value.enabled ?? true
}
try {
await api.notify.saveChannel(channelData)
toast.success('保存成功')
showDialog.value = false
await loadData()
} catch (e: any) {
toast.error('保存失败: ' + e.message)
}
}
function confirmDelete(id: string) {
deletingChannelId.value = id
showDeleteConfirm.value = true
}
async function deleteChannel() {
showDeleteConfirm.value = false
try {
await api.notify.deleteChannel(deletingChannelId.value)
toast.success('删除成功')
await loadData()
} catch (e: any) {
toast.error('删除失败: ' + e.message)
}
}
async function testChannel(ch: NotifyChannel) {
try {
const result = await api.notify.testChannel(ch)
if (result.success) {
toast.success('测试发送成功!')
} else {
toast.error('测试发送失败: ' + (result.error || '未知错误'))
}
} catch (e: any) {
toast.error('测试失败: ' + e.message)
}
}
// 事件绑定
async function saveBindings(newBindings: Partial<NotifyBinding>[]) {
try {
for (const binding of newBindings) {
await api.notify.saveBinding(binding)
}
toast.success('绑定保存成功')
await loadEvents()
} catch (e: any) {
toast.error('保存失败: ' + e.message)
}
}
async function deleteBinding(id: string) {
try {
await api.notify.deleteBinding(id)
toast.success('绑定已删除')
await loadEvents()
} catch (e: any) {
toast.error('删除失败: ' + e.message)
}
}
// API Token
async function loadApiToken() {
try {
const token = await api.settings.get('notify', 'notify_token')
console.log('Loaded API Token:', token, 'Type:', typeof token, 'Length:', token?.length)
apiToken.value = token || ''
} catch (e: any) {
console.error('加载 API Token 失败:', e)
// 如果是404或者其他错误,token保持为空字符串
apiToken.value = ''
}
}
async function generateApiToken() {
try {
const newToken = await api.settings.generateToken('notify', 'notify_token')
apiToken.value = newToken
toast.success('API Token 已生成')
} catch (e: any) {
toast.error('生成失败: ' + e.message)
}
}
async function copyApiToken() {
if (!apiToken.value) {
toast.error('请先生成 Token')
return
}
try {
await navigator.clipboard.writeText(apiToken.value)
toast.success('Token 已复制到剪贴板')
} catch {
toast.error('复制失败')
}
}
async function copyApiExample() {
if (channels.value.length === 0) {
toast.error('请先添加一个通知渠道')
return
}
const ch = channels.value[0]
if (!ch) return
const token = apiToken.value || 'YOUR_API_TOKEN'
const example = `curl -X POST "{{API_URL}}/api/v1/notify/send" \\
-H "Content-Type: application/json" \\
-H "notify-token: ${token}" \\
-d '{"channel_id": "${ch.id}", "title": "测试通知", "text": "来自脚本的通知"}'`
try {
await navigator.clipboard.writeText(example)
toast.success('API 调用示例已复制到剪贴板')
} catch {
toast.error('复制失败')
}
}
onMounted(() => {
loadData()
loadEvents()
loadApiToken()
})
</script>
<template>
<div class="space-y-6">
<Tabs v-model="activeTab" class="w-full">
<div class="flex flex-col sm:flex-row sm:items-center justify-between gap-4 mb-6">
<div>
<h2 class="text-xl sm:text-2xl font-bold tracking-tight">消息推送</h2>
<p class="text-muted-foreground text-sm">配置通知渠道绑定系统事件实现自动推送</p>
</div>
<TabsList class="w-full sm:w-auto grid grid-cols-3 sm:inline-flex h-9 gap-1 p-1">
<TabsTrigger value="channels" class="text-xs px-3 py-1">渠道管理</TabsTrigger>
<TabsTrigger value="events" class="text-xs px-3 py-1">事件绑定</TabsTrigger>
<TabsTrigger value="api" class="text-xs px-3 py-1">脚本调用</TabsTrigger>
</TabsList>
</div>
<!-- 渠道管理 -->
<TabsContent value="channels">
<ChannelList
:channels="channels"
:channel-types="channelTypes"
@add="openNewChannel"
@edit="openEditChannel"
@delete="confirmDelete"
@test="testChannel"
/>
</TabsContent>
<!-- 事件绑定 -->
<TabsContent value="events">
<EventBinding
:channels="channels"
:channel-types="channelTypes"
:event-types="eventTypes"
:bindings="bindings"
:tasks="allTasks"
@save="saveBindings"
@delete="deleteBinding"
/>
</TabsContent>
<!-- 脚本调用 -->
<TabsContent value="api">
<ApiUsage
:channels="channels"
:channel-types="channelTypes"
:api-token="apiToken"
@generate-token="generateApiToken"
@copy-token="copyApiToken"
@copy-example="copyApiExample"
/>
</TabsContent>
</Tabs>
<!-- 添加/编辑渠道弹窗 -->
<ChannelDialog
v-model:open="showDialog"
:is-editing="isEditing"
v-model:channel="editingChannel"
:channel-types="channelTypes"
:config-fields="channelConfigFields"
@type-change="onTypeChange"
@save="saveChannel"
/>
<!-- 删除确认 -->
<AlertDialog :open="showDeleteConfirm" @update:open="showDeleteConfirm = $event">
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>确认删除</AlertDialogTitle>
<AlertDialogDescription>
删除后将无法恢复同时会取消该渠道的所有事件绑定确定要删除吗
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>取消</AlertDialogCancel>
<AlertDialogAction @click="deleteChannel">确认删除</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
</template>
@@ -0,0 +1,200 @@
<script setup lang="ts">
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { Badge } from '@/components/ui/badge'
import { Copy, Terminal, Key, FileJson, RefreshCw, Check, Hash, Info } from 'lucide-vue-next'
import type { NotifyChannel, ChannelType } from '@/api'
import { ref, computed } from 'vue'
import { toast } from 'vue-sonner'
const props = defineProps<{
channels: NotifyChannel[]
channelTypes: ChannelType[]
apiToken: string
}>()
const emit = defineEmits<{
generateToken: []
copyToken: []
copyExample: []
}>()
const copiedBlock = ref<string | null>(null)
const host = ref(window.location.host)
function copyToClipboard(text: string, blockId: string) {
navigator.clipboard.writeText(text).then(() => {
copiedBlock.value = blockId
toast.success('已复制到剪贴板')
setTimeout(() => {
copiedBlock.value = null
}, 2000)
})
}
const apiExample = `POST /api/v1/notify/send
Content-Type: application/json
notify-token: <你的API Token>
{
"channel_id": "渠道ID",
"title": "标题",
"text": "内容"
}`
const shellExample = computed(() => `curl -s -X POST "http://${host.value}/api/v1/notify/send" \\
-H "Content-Type: application/json" \\
-H "notify-token: ${props.apiToken || 'YOUR_TOKEN'}" \\
-d '{"channel_id":"YOUR_CHANNEL_ID","title":"标题","text":"通知内容"}'`)
</script>
<template>
<div class="space-y-6">
<!-- API Token 管理卡片 -->
<Card class="border bg-card shadow-sm overflow-hidden">
<CardHeader class="pb-4">
<div class="flex items-center gap-2 mb-1">
<div class="p-1.5 rounded-md bg-primary/10 text-primary">
<Key class="w-4 h-4" />
</div>
<CardTitle class="text-base font-semibold">身份验证 (API Token)</CardTitle>
</div>
<CardDescription>用于外部脚本或工具调用 API 时的安全凭证</CardDescription>
</CardHeader>
<CardContent class="space-y-4">
<div class="flex items-center gap-3">
<div class="relative flex-1 group">
<Input
:model-value="apiToken"
readonly
placeholder="尚未生成 Token"
class="h-10 pr-10 bg-muted/30 border-muted-foreground/20 focus-visible:ring-primary/30 font-code text-sm tracking-tight"
/>
<div
v-if="apiToken"
@click="copyToClipboard(apiToken, 'token')"
class="absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-primary cursor-pointer transition-colors p-1 rounded-md hover:bg-muted"
title="复制 Token"
>
<Check v-if="copiedBlock === 'token'" class="w-4 h-4 text-emerald-500 animate-in zoom-in" />
<Copy v-else class="w-4 h-4" />
</div>
</div>
<Button variant="default" @click="emit('generateToken')" class="h-10 px-4 shrink-0 transition-all active:scale-95">
<RefreshCw class="w-3.5 h-3.5 mr-2" />
{{ apiToken ? '重新生成' : '生成 Token' }}
</Button>
</div>
<div class="flex items-start gap-2 p-3 rounded-lg bg-amber-500/5 border border-amber-500/10 text-[13px] text-amber-700 dark:text-amber-400">
<Info class="w-4 h-4 mt-0.5 shrink-0" />
<p>请妥善保管您的 Token一旦丢失需通过上方按钮重新生成令牌将作为请求头中的 <code>notify-token</code> 字段发送</p>
</div>
</CardContent>
</Card>
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
<!-- API 接口规格 -->
<Card class="border bg-card shadow-sm flex flex-col overflow-hidden">
<CardHeader class="pb-3 shrink-0">
<div class="flex items-center justify-between">
<div class="flex items-center gap-2">
<div class="p-1.5 rounded-md bg-emerald-500/10 text-emerald-600">
<FileJson class="w-4 h-4" />
</div>
<CardTitle class="text-sm font-bold uppercase tracking-wider">API 接口说明</CardTitle>
</div>
</div>
</CardHeader>
<CardContent class="p-0 flex-1">
<div class="bg-zinc-950 dark:bg-black/40 p-5 font-code text-xs sm:text-sm leading-relaxed text-zinc-300 relative group h-full">
<div class="flex items-center justify-between mb-4 border-b border-zinc-700/50 pb-2">
<div class="flex items-center gap-2">
<Badge class="bg-emerald-600 text-white border-none py-0 px-2 text-[10px]">POST</Badge>
<code class="text-zinc-400">/api/v1/notify/send</code>
</div>
<Button
variant="ghost"
size="icon"
class="h-7 w-7 text-zinc-500 hover:text-white hover:bg-zinc-800 transition-all"
@click="copyToClipboard(apiExample, 'api')"
>
<Check v-if="copiedBlock === 'api'" class="w-3.5 h-3.5 text-emerald-500" />
<Copy v-else class="w-3.5 h-3.5" />
</Button>
</div>
<div class="space-y-4">
<div>
<span class="text-zinc-500 block mb-1 uppercase text-[10px] font-bold tracking-widest">Headers</span>
<div class="pl-2 space-y-1">
<p><span class="text-zinc-500">Content-Type:</span> application/json</p>
<p><span class="text-zinc-500">notify-token:</span> <span class="text-primary">&lt;TOKEN&gt;</span></p>
</div>
</div>
<div>
<span class="text-zinc-500 block mb-1 uppercase text-[10px] font-bold tracking-widest">Body (JSON)</span>
<div class="pl-2">
<p class="text-zinc-400">{</p>
<p class="pl-4">"channel_id": <span class="text-orange-400">"ID"</span>, <span class="text-zinc-500 font-sans italic text-[11px]">// 渠道唯一标识</span></p>
<p class="pl-4">"title": <span class="text-orange-400">"标题"</span>, <span class="text-zinc-500 font-sans italic text-[11px]">// 可选</span></p>
<p class="pl-4">"text": <span class="text-orange-400">"内容"</span> <span class="text-zinc-500 font-sans italic text-[11px]">// 必填</span></p>
<p class="text-zinc-400">}</p>
</div>
</div>
</div>
</div>
</CardContent>
</Card>
<!-- Shell 示例 -->
<Card class="border bg-card shadow-sm flex flex-col overflow-hidden">
<CardHeader class="pb-3 shrink-0">
<div class="flex items-center justify-between">
<div class="flex items-center gap-2">
<div class="p-1.5 rounded-md bg-sky-500/10 text-sky-600">
<Terminal class="w-4 h-4" />
</div>
<CardTitle class="text-sm font-bold uppercase tracking-wider">Shell 脚本示例</CardTitle>
</div>
<Button
variant="outline"
size="sm"
class="h-7 px-2 text-[10px] border-muted-foreground/30 hover:bg-muted transition-all"
@click="copyToClipboard(shellExample, 'shell')"
>
<Check v-if="copiedBlock === 'shell'" class="w-3 h-3 text-emerald-500 mr-1.5" />
<Copy v-else class="w-3 h-3 mr-1.5" />
一键复制
</Button>
</div>
</CardHeader>
<CardContent class="p-0 flex-1">
<div class="bg-zinc-950 dark:bg-black/40 p-5 font-code text-[12px] sm:text-[13px] leading-relaxed text-zinc-300 h-full">
<div class="space-y-1">
<p><span class="text-zinc-600"># 使用 CURL 调用推送接口</span></p>
<p>curl -s -X POST <span class="text-emerald-400">"http://{{ host }}/api/v1/notify/send"</span> \</p>
<p class="pl-4"> -H <span class="text-orange-400">"Content-Type: application/json"</span> \</p>
<p class="pl-4"> -H <span class="text-orange-400">"notify-token: {{ apiToken || 'YOUR_TOKEN' }}"</span> \</p>
<p class="pl-4"> -d <span class="text-orange-400">'{"channel_id":"ID","title":"任务完成","text":"脚本执行完毕"}'</span></p>
</div>
<div class="mt-8 pt-4 border-t border-zinc-800">
<span class="text-zinc-500 block mb-2 uppercase text-[10px] font-bold tracking-widest flex items-center gap-1.5">
<Hash class="w-3 h-3" /> 渠道 ID 快速查找
</span>
<div v-if="channels.length === 0" class="text-xs text-zinc-600 italic">暂无活跃渠道</div>
<div v-else class="grid grid-cols-1 sm:grid-cols-2 gap-2">
<div v-for="ch in channels" :key="ch.id" class="flex items-center gap-2 text-xs bg-zinc-900 px-2 py-1.5 rounded border border-zinc-800 hover:border-zinc-700 transition-colors group">
<code class="text-primary font-bold tracking-tighter font-code">{{ ch.id.slice(0, 8) }}</code>
<span class="text-zinc-500 truncate max-w-[100px]">{{ ch.name }}</span>
</div>
</div>
</div>
</div>
</CardContent>
</Card>
</div>
</div>
</template>
@@ -0,0 +1,137 @@
<script setup lang="ts">
import { computed } from 'vue'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Switch } from '@/components/ui/switch'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import type { NotifyChannel, ChannelType } from '@/api'
const props = defineProps<{
open: boolean
isEditing: boolean
channel: Partial<NotifyChannel>
channelTypes: ChannelType[]
configFields: Record<string, { key: string; label: string; required: boolean; placeholder?: string; type?: string }[]>
}>()
const emit = defineEmits<{
'update:open': [value: boolean]
'update:channel': [channel: Partial<NotifyChannel>]
'type-change': [type: string]
save: []
}>()
const currentConfigFields = computed(() => {
return props.configFields[props.channel.type || ''] || []
})
const enabledModel = computed({
get: () => props.channel.enabled ?? true,
set: (value) => updateChannelField('enabled', value)
})
function updateChannelField(field: string, value: any) {
emit('update:channel', { ...props.channel, [field]: value })
}
function updateConfigField(key: string, value: string) {
const newConfig = { ...props.channel.config, [key]: value }
emit('update:channel', { ...props.channel, config: newConfig })
}
</script>
<template>
<Dialog :open="open" @update:open="emit('update:open', $event)">
<DialogContent class="sm:max-w-lg max-h-[80vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>{{ isEditing ? '编辑渠道' : '添加渠道' }}</DialogTitle>
<DialogDescription>配置消息推送渠道</DialogDescription>
</DialogHeader>
<div class="space-y-4 py-2">
<div class="grid grid-cols-4 items-center gap-3">
<Label class="text-right text-sm">名称</Label>
<Input
:model-value="channel.name"
@update:model-value="updateChannelField('name', $event)"
placeholder="给渠道起个名字"
class="col-span-3"
/>
</div>
<div class="grid grid-cols-4 items-center gap-3">
<Label class="text-right text-sm">类型</Label>
<div class="col-span-3">
<Select
:model-value="channel.type"
@update:model-value="(val: any) => emit('type-change', String(val))"
:disabled="isEditing"
>
<SelectTrigger>
<SelectValue placeholder="选择渠道类型" />
</SelectTrigger>
<SelectContent>
<SelectItem v-for="ct in channelTypes" :key="ct.type" :value="ct.type">
{{ ct.label }}
</SelectItem>
</SelectContent>
</Select>
</div>
</div>
<div class="grid grid-cols-4 items-center gap-3">
<Label class="text-right text-sm">启用</Label>
<Switch v-model="enabledModel" />
</div>
<div v-if="currentConfigFields.length > 0" class="border-t pt-4 mt-4">
<h4 class="text-sm font-medium mb-3 text-muted-foreground">渠道配置</h4>
<div class="space-y-3">
<div v-for="field in currentConfigFields" :key="field.key" class="grid grid-cols-4 items-start gap-3">
<Label class="text-right text-sm pt-2">
{{ field.label }}
<span v-if="field.required" class="text-destructive">*</span>
</Label>
<div class="col-span-3">
<Input
v-if="!field.type || field.type !== 'textarea'"
:model-value="channel.config?.[field.key] || ''"
@update:model-value="updateConfigField(field.key, String($event))"
:placeholder="field.placeholder || ''"
class="text-sm"
/>
<textarea
v-else
:value="channel.config?.[field.key] || ''"
@input="(e: Event) => updateConfigField(field.key, (e.target as HTMLTextAreaElement).value)"
:placeholder="field.placeholder || ''"
class="w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring min-h-[80px]"
/>
</div>
</div>
</div>
</div>
</div>
<DialogFooter>
<Button variant="outline" @click="emit('update:open', false)">取消</Button>
<Button @click="emit('save')">保存</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</template>
@@ -0,0 +1,82 @@
<script setup lang="ts">
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { Plus, Trash2, TestTube, Pencil, Bell } from 'lucide-vue-next'
import type { NotifyChannel, ChannelType } from '@/api'
defineProps<{
channels: NotifyChannel[]
channelTypes: ChannelType[]
}>()
const emit = defineEmits<{
add: []
edit: [channel: NotifyChannel]
delete: [id: string]
test: [channel: NotifyChannel]
}>()
function getChannelTypeName(type: string, channelTypes: ChannelType[]): string {
const found = channelTypes.find(t => t.type === type)
return found ? found.label : type
}
</script>
<template>
<Card>
<CardHeader>
<div class="flex items-center justify-between">
<div>
<CardTitle>通知渠道</CardTitle>
<CardDescription>管理消息推送渠道配置</CardDescription>
</div>
<Button size="sm" @click="emit('add')">
<Plus class="w-4 h-4 mr-1" />
添加渠道
</Button>
</div>
</CardHeader>
<CardContent>
<div v-if="channels.length === 0" class="text-center py-12 text-muted-foreground">
<Bell class="w-12 h-12 mx-auto mb-3 opacity-30" />
<p class="text-sm">暂无通知渠道</p>
<p class="text-xs mt-1">点击"添加渠道"开始配置</p>
</div>
<div v-else class="space-y-3">
<div
v-for="ch in channels"
:key="ch.id"
class="flex items-center justify-between p-3 rounded-lg border bg-card hover:bg-accent/30 transition-colors"
>
<div class="flex items-center gap-3 min-w-0 flex-1">
<div class="flex flex-col min-w-0">
<div class="flex items-center gap-2">
<span class="font-medium text-sm truncate">{{ ch.name }}</span>
<Badge variant="secondary" class="text-[10px] shrink-0">{{ getChannelTypeName(ch.type, channelTypes) }}</Badge>
<Badge
:class="ch.enabled ? 'bg-emerald-500/10 text-emerald-600 dark:text-emerald-400 border-emerald-500/20' : 'bg-zinc-500/10 text-zinc-500 border-zinc-500/20'"
variant="secondary"
class="text-[10px] shrink-0"
>
{{ ch.enabled ? '启用' : '禁用' }}
</Badge>
</div>
</div>
</div>
<div class="flex items-center gap-1 shrink-0">
<Button variant="ghost" size="icon" class="h-7 w-7" @click="emit('test', ch)" title="测试发送">
<TestTube class="w-3.5 h-3.5" />
</Button>
<Button variant="ghost" size="icon" class="h-7 w-7" @click="emit('edit', ch)" title="编辑">
<Pencil class="w-3.5 h-3.5" />
</Button>
<Button variant="ghost" size="icon" class="h-7 w-7 text-destructive hover:text-destructive" @click="emit('delete', ch.id)" title="删除">
<Trash2 class="w-3.5 h-3.5" />
</Button>
</div>
</div>
</div>
</CardContent>
</Card>
</template>
@@ -0,0 +1,296 @@
<script setup lang="ts">
import { ref, computed } from 'vue'
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import { X, Plus, Shield, Terminal, Search } from 'lucide-vue-next'
import type { NotifyChannel, ChannelType, EventType, NotifyBinding, Task } from '@/api'
const props = defineProps<{
channels: NotifyChannel[]
channelTypes: ChannelType[]
eventTypes: EventType[]
bindings: NotifyBinding[]
tasks: Task[]
}>()
const emit = defineEmits<{
save: [bindings: Partial<NotifyBinding>[]]
delete: [id: string]
}>()
// --- 状态管理 ---
const selectedTaskId = ref<string>('') // 默认不选中任何任务,强制用户选择
const selectedChannels = ref<Record<string, string>>({})
const taskSearchQuery = ref('')
const isTaskDropdownOpen = ref(false)
// 任务过滤逻辑
const filteredTasks = computed(() => {
if (!taskSearchQuery.value) return props.tasks
const q = taskSearchQuery.value.toLowerCase()
return props.tasks.filter(t => t.name.toLowerCase().includes(q))
})
// 获取当前选择的任务显示名称
const selectedTaskDisplay = computed(() => {
const task = props.tasks.find(t => t.id === selectedTaskId.value)
return task ? task.name : '请选择任务'
})
function selectTask(id: string) {
selectedTaskId.value = id
isTaskDropdownOpen.value = false
taskSearchQuery.value = '' // 清空搜索词
}
// 分组事件
const systemEvents = computed(() => props.eventTypes.filter(e => e.binding_type === 'system'))
const taskEvents = computed(() => props.eventTypes.filter(e => e.binding_type === 'task'))
// 获取事件的绑定渠道
function getBindings(eventType: string, isSystem: boolean): NotifyBinding[] {
if (isSystem) {
return props.bindings.filter(b => b.event === eventType && b.type === 'system')
}
return props.bindings.filter(b => b.event === eventType && b.type === 'task' && b.data_id === selectedTaskId.value)
}
// 获取渠道名称与类型标签
function getChannelName(wayId: string): string {
const channel = props.channels.find(c => c.id === wayId)
return channel ? channel.name : '未知渠道'
}
function getChannelTypeLabel(wayId: string): string {
const channel = props.channels.find(c => c.id === wayId)
if (!channel) return ''
const type = props.channelTypes.find(t => t.type === channel.type)
return type ? type.label : channel.type
}
// 添加渠道到事件
function addChannelToEvent(eventType: string, bindingType: 'system' | 'task') {
const dataId = bindingType === 'task' ? selectedTaskId.value : ''
const key = `${eventType}-${bindingType}-${dataId}`
const channelId = selectedChannels.value[key]
if (!channelId) return
const newBinding: Partial<NotifyBinding> = {
type: bindingType,
event: eventType,
way_id: channelId,
data_id: dataId
}
emit('save', [newBinding])
selectedChannels.value[key] = ''
}
// 获取可选的渠道
function getAvailableChannels(eventType: string, bindingType: 'system' | 'task') {
const isSystem = bindingType === 'system'
const boundChannelIds = getBindings(eventType, isSystem).map(b => b.way_id)
return props.channels.filter(c => !boundChannelIds.includes(c.id) && c.enabled)
}
// 删除绑定
function removeBinding(binding: NotifyBinding) {
emit('delete', binding.id)
}
</script>
<template>
<div class="space-y-6">
<!-- 系统事件卡片 -->
<Card>
<CardHeader>
<div class="flex items-center gap-2">
<Shield class="w-5 h-5 text-primary" />
<div>
<CardTitle>系统事件</CardTitle>
<CardDescription>配置帐号登录安全告警等系统级事件的通知推送</CardDescription>
</div>
</div>
</CardHeader>
<CardContent class="space-y-4">
<div v-if="channels.length === 0"
class="text-center py-8 text-muted-foreground border-2 border-dashed rounded-lg">
<p class="text-sm">请先在渠道管理中添加通知渠道</p>
</div>
<div v-else class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div v-for="event in systemEvents" :key="event.type"
class="flex flex-col p-4 rounded-lg border bg-card hover:bg-accent/10 transition-colors">
<div class="flex items-center justify-between mb-3">
<div class="flex items-center gap-2">
<span class="font-bold text-sm">{{ event.label }}</span>
<Badge variant="outline" class="text-[10px] font-mono opacity-50">{{ event.type }}</Badge>
</div>
</div>
<!-- 已绑定渠道 -->
<div class="flex flex-wrap gap-2 mb-3 min-h-[32px] items-center">
<template v-if="getBindings(event.type, true).length > 0">
<div v-for="binding in getBindings(event.type, true)" :key="binding.id"
class="inline-flex items-center gap-1.5 px-2 py-1 rounded bg-secondary/50 border text-[11px] font-medium">
<span class="truncate max-w-[100px]">{{ getChannelName(binding.way_id) }}</span>
<button @click="removeBinding(binding)"
class="hover:text-destructive p-0.5 rounded-sm transition-colors">
<X class="w-3 h-3" />
</button>
</div>
</template>
<span v-else class="text-[11px] text-muted-foreground italic">未绑定渠道</span>
</div>
<!-- 添加绑定 -->
<div class="flex gap-2">
<Select v-model="selectedChannels[`${event.type}-system-`]">
<SelectTrigger class="h-8 text-xs flex-1">
<SelectValue placeholder="添加通知渠道" />
</SelectTrigger>
<SelectContent>
<SelectItem v-for="ch in getAvailableChannels(event.type, 'system')" :key="ch.id" :value="ch.id">
<div class="flex items-center gap-2">
<span class="text-xs">{{ ch.name }}</span>
<Badge variant="outline" class="text-[9px]">{{ getChannelTypeLabel(ch.id) }}</Badge>
</div>
</SelectItem>
</SelectContent>
</Select>
<Button size="sm" variant="outline" class="h-8 px-2" @click="addChannelToEvent(event.type, 'system')"
:disabled="!selectedChannels[`${event.type}-system-`]">
<Plus class="w-3.5 h-3.5" />
</Button>
</div>
</div>
</div>
</CardContent>
</Card>
<!-- 任务事件卡片 -->
<Card>
<CardHeader>
<div class="flex flex-col sm:flex-row sm:items-center justify-between gap-4">
<div class="flex items-center gap-2">
<Terminal class="w-5 h-5 text-primary" />
<div>
<CardTitle>任务事件</CardTitle>
<CardDescription>配置定时任务执行状态的通知行为需选择具体任务</CardDescription>
</div>
</div>
<div class="relative w-full sm:w-auto min-w-[240px]">
<div
class="flex items-center gap-2 bg-transparent border border-input rounded-md px-3 h-9 focus-within:ring-1 focus-within:ring-ring/30 transition-all cursor-pointer"
@click="isTaskDropdownOpen = !isTaskDropdownOpen">
<Search class="w-4 h-4 text-muted-foreground shrink-0" />
<div class="flex-1 text-xs truncate">
<span v-if="!isTaskDropdownOpen" class="font-medium"
:class="{ 'text-muted-foreground': !selectedTaskId }">{{ selectedTaskDisplay }}</span>
<input v-else v-model="taskSearchQuery"
class="w-full bg-transparent border-none outline-none p-0 text-xs" placeholder="搜索任务名..." @click.stop
autofocus />
</div>
<X v-if="taskSearchQuery || isTaskDropdownOpen"
class="w-3 h-3 text-muted-foreground hover:text-destructive"
@click.stop="taskSearchQuery = ''; isTaskDropdownOpen = false" />
</div>
<!-- 自定义下拉列表 -->
<div v-if="isTaskDropdownOpen"
class="absolute top-full left-0 w-full mt-1 bg-card border border-border rounded-md shadow-xl z-50 max-h-[250px] overflow-auto py-1 animate-in fade-in zoom-in-95 duration-150">
<div v-if="filteredTasks.length === 0"
class="px-3 py-4 text-center text-[10px] text-muted-foreground italic">
未找到相关任务
</div>
<div v-for="task in filteredTasks" :key="task.id"
class="px-3 py-1.5 text-xs hover:bg-accent cursor-pointer flex items-center justify-between"
:class="{ 'bg-accent/50 font-medium': selectedTaskId === task.id }" @click="selectTask(task.id)">
<span class="truncate">{{ task.name }}</span>
<Badge v-if="selectedTaskId === task.id" variant="outline" class="h-4 p-0 px-1 text-[8px]">当前</Badge>
</div>
</div>
</div>
</div>
</CardHeader>
<CardContent class="space-y-4">
<!-- 未选择任务时的提示 -->
<div v-if="!selectedTaskId"
class="flex flex-col items-center justify-center py-12 text-muted-foreground border-2 border-dashed rounded-lg bg-accent/5">
<Terminal class="w-10 h-10 mb-3 opacity-20" />
<p class="text-sm font-medium">请在右上角选择需要配置的任务</p>
<p class="text-[11px] opacity-70 mt-1">每个任务可以独立配置通知渠道</p>
</div>
<template v-else>
<!-- 任务特定模式提示 -->
<div class="flex items-center justify-between p-3 rounded-lg bg-primary/5 border border-primary/20">
<div class="flex items-center gap-2">
<Badge class="bg-primary/20 text-primary border-none text-[10px]">当前任务</Badge>
<span class="text-xs font-bold truncate max-w-[200px]">{{tasks.find(t => t.id === selectedTaskId)?.name
}}</span>
</div>
</div>
<div v-if="channels.length === 0"
class="text-center py-8 text-muted-foreground border-2 border-dashed rounded-lg">
<p class="text-sm">请先添加通知渠道</p>
</div>
<div v-else class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
<div v-for="event in taskEvents" :key="event.type"
class="flex flex-col p-4 rounded-lg border bg-card hover:bg-accent/10 transition-colors">
<div class="flex items-center gap-2 mb-3">
<span class="font-bold text-sm">{{ event.label }}</span>
<Badge variant="outline" class="text-[10px] font-mono opacity-50">{{ event.type }}</Badge>
</div>
<!-- 已绑定渠道 -->
<div class="flex flex-wrap gap-2 mb-3 min-h-[32px] items-center">
<template v-if="getBindings(event.type, false).length > 0">
<div v-for="binding in getBindings(event.type, false)" :key="binding.id"
class="inline-flex items-center gap-1.5 px-2 py-1 rounded bg-secondary/50 border text-[11px] font-medium">
<span class="truncate max-w-[100px]">{{ getChannelName(binding.way_id) }}</span>
<button @click="removeBinding(binding)"
class="hover:text-destructive p-0.5 rounded-sm transition-colors">
<X class="w-3 h-3" />
</button>
</div>
</template>
<span v-else class="text-[11px] text-muted-foreground italic">未绑定渠道</span>
</div>
<!-- 添加绑定 -->
<div class="flex gap-2 font-sans">
<Select v-model="selectedChannels[`${event.type}-task-${selectedTaskId}`]">
<SelectTrigger class="h-8 text-xs flex-1">
<SelectValue placeholder="添加通知渠道" />
</SelectTrigger>
<SelectContent>
<SelectItem v-for="ch in getAvailableChannels(event.type, 'task')" :key="ch.id" :value="ch.id">
<div class="flex items-center gap-2">
<span class="text-xs">{{ ch.name }}</span>
<Badge variant="outline" class="text-[9px]">{{ getChannelTypeLabel(ch.id) }}</Badge>
</div>
</SelectItem>
</SelectContent>
</Select>
<Button size="sm" variant="outline" class="h-8 px-2" @click="addChannelToEvent(event.type, 'task')"
:disabled="!selectedChannels[`${event.type}-task-${selectedTaskId}`]">
<Plus class="w-3.5 h-3.5" />
</Button>
</div>
</div>
</div>
</template>
</CardContent>
</Card>
</div>
</template>