commit 778c46a691278e3b837e0c58d9dc4b38e4aa1533 Author: 白茶清欢 Date: Wed Aug 12 11:42:08 2026 +0800 feat: 完成一般消息通知能力的开发 diff --git a/README.md b/README.md new file mode 100644 index 0000000..162093a --- /dev/null +++ b/README.md @@ -0,0 +1,250 @@ +# notice + +`notice` 是一个 Go 多渠道消息通知 package,统一接入飞书、钉钉、企业微信和通用 Webhook。调用方只需要构造统一的 `Message`,渠道差异、签名和响应业务码均由 package 内部处理。 + +HTTP 请求统一使用 [`git.zhangdeman.cn/zhangdeman/network`](https://git.zhangdeman.cn/zhangdeman/network),每次发送只调用一次目标 Webhook,不在 package 内自动重试。 + +## 安装 + +```bash +go get git.zhangdeman.cn/zhangdeman/notice +``` + +当前版本依赖 Go 1.25 及以上版本。 + +## 支持能力 + +| 消息类型 | 飞书 | 钉钉 | 企业微信 | 通用 Webhook | +|---|---|---|---|---| +| 普通文本 | Text | Text | Text | 统一 JSON | +| 带颜色文本 | 彩色卡片文本 | Markdown 语义前缀 | Markdown 内置颜色 | 统一 JSON | +| Markdown | 交互卡片 | Markdown | Markdown | 统一 JSON | +| 卡片 | Card JSON 2.0 | ActionCard | Template Card | 统一 JSON | +| 卡片主题 | 支持 | 保留内容 | 保留内容 | 原样传递 | + +## 快速开始 + +### 创建 Factory + +```go +package main + +import ( + "context" + "os" + "time" + + "git.zhangdeman.cn/zhangdeman/notice" +) + +func main() { + factory := notice.NewFactory(map[notice.Channel]notice.Config{ + notice.ChannelFeishu: { + Webhook: os.Getenv("FEISHU_WEBHOOK"), + Security: ¬ice.SecurityConfig{ + SignEnabled: true, + SignSecret: os.Getenv("FEISHU_SIGN_SECRET"), + }, + Timeout: 5 * time.Second, + }, + notice.ChannelDingTalk: { + Webhook: os.Getenv("DINGTALK_WEBHOOK"), + Security: ¬ice.SecurityConfig{ + SignEnabled: true, + SignSecret: os.Getenv("DINGTALK_SIGN_SECRET"), + }, + Timeout: 5 * time.Second, + }, + notice.ChannelWeCom: { + Webhook: os.Getenv("WECOM_WEBHOOK"), + Timeout: 5 * time.Second, + }, + }) + + sender, err := factory.Get(notice.ChannelFeishu) + if err != nil { + panic(err) + } + if err = sender.Send(context.Background(), notice.Text("服务启动完成")); err != nil { + panic(err) + } +} +``` + +`Factory.Get(channel)` 会缓存初始化成功的 `Sender`。同一 Factory 内再次获取相同渠道时直接返回已有实例,并发调用也只会初始化一个实例。 + +也可以绕过 Factory,直接创建单个渠道实例: + +```go +sender, err := notice.New(notice.ChannelWebhook, notice.Config{ + Webhook: "https://example.com/webhook", +}) +``` + +Webhook 地址必须为 HTTPS;`Timeout` 为空时默认 5 秒。 + +### 独立签名校验 + +每个机器人配置拥有独立的安全校验开关和密钥: + +```go +Security: ¬ice.SecurityConfig{ + SignEnabled: true, + SignSecret: os.Getenv("ROBOT_SIGN_SECRET"), +}, +``` + +- 飞书:在请求体中加入秒级 `timestamp` 和 `sign`。 +- 钉钉:在 Webhook 查询参数中加入毫秒级 `timestamp` 和 `sign`。 +- `SignEnabled` 为 `false` 或未设置 `Security` 时不生成签名。 +- 启用签名后 `SignSecret` 不能为空。 +- 企业微信群机器人和通用 Webhook 不启用该签名配置。 + +`SecurityConfig` 属于单个渠道的 `Config`,因此不同机器人可以分别启用签名并使用不同密钥。 + +## 消息格式 + +### 普通文本 + +```go +err := sender.Send(ctx, notice.Text("数据同步任务已完成")) +``` + +### 带颜色文本 + +```go +message := notice.ColorText( + notice.TextSegment{Text: "发布状态:"}, + notice.TextSegment{ + Text: "失败", + Color: notice.ColorDanger, + Bold: true, + }, +) + +err := sender.Send(ctx, message) +``` + +支持 `default`、`info`、`success`、`warning`、`danger` 和 `muted` 六种语义颜色。各平台会转换为最接近的原生显示效果。 + +### Markdown + +```go +err := sender.Send(ctx, notice.Markdown( + "订单服务告警", + "**错误率超过阈值**\n\n当前值:3.2%", +)) +``` + +### 卡片消息 + +```go +message := notice.Card(notice.CardContent{ + Title: "发布结果", + Theme: notice.CardThemeGreen, + Markdown: "服务已成功发布到生产环境。", + Fields: []notice.CardField{ + {Name: "服务", Value: "order-service"}, + {Name: "版本", Value: "v1.8.0"}, + }, + Actions: []notice.CardAction{ + {Text: "查看发布详情", URL: "https://example.com/releases/1001"}, + }, +}) + +err := sender.Send(ctx, message) +``` + +飞书支持 `default`、`blue`、`wathet`、`turquoise`、`green`、`yellow`、`orange`、`red`、`carmine`、`violet`、`purple`、`indigo` 和 `grey` 卡片主题。卡片操作地址必须使用 HTTPS。 + +## 同步与异步发送 + +同步发送直接返回结果: + +```go +if err := sender.Send(ctx, message); err != nil { + return err +} +``` + +异步发送返回容量为 1 的只读错误通道: + +```go +result := sender.SendAsync(ctx, message) + +go func() { + if err := <-result; err != nil { + log.Printf("send notice failed: %v", err) + } +}() +``` + +异步任务运行在当前进程中。调用方需要保证进程和 `Context` 在发送完成前仍然有效。 + +## 包装 Zap Logger + +`WrapZap` 在原 Logger Core 之外附加通知 Core,不改变原有控制台或文件日志输出。 + +```go +base, err := zap.NewProduction() +if err != nil { + return err +} + +logger, err := factory.WrapZap(base, notice.ZapConfig{ + Channels: []notice.Channel{ + notice.ChannelFeishu, + notice.ChannelDingTalk, + }, + OnError: func(err error) { + // 使用独立的处理方式,避免写回 logger 形成递归通知。 + fmt.Printf("send log notice failed: %v\n", err) + }, +}) +if err != nil { + return err +} +defer logger.Sync() + +logger.Info("服务启动完成") +logger.Warn("订单延迟升高", zap.Int("delay_ms", 2500)) +logger.Error("订单创建失败", zap.String("order_id", "O1001")) +``` + +`Levels` 为空时,仅精确匹配 `WarnLevel` 和 `ErrorLevel`。自定义等级示例: + +```go +logger, err := factory.WrapZap(base, notice.ZapConfig{ + Channels: []notice.Channel{notice.ChannelWeCom}, + Levels: []zapcore.Level{ + zapcore.ErrorLevel, + zapcore.DPanicLevel, + }, +}) +``` + +通知异步发送到指定渠道。`logger.Sync()` 会等待已触发的通知结束,适合在程序退出前调用。日志消息会转换为卡片,包含等级、Logger 名称、时间、调用位置以及 Zap 字段。 + +## 错误处理 + +- 参数错误可通过 `errors.Is` 判断 `ErrUnsupportedChannel`、`ErrChannelNotConfigured`、`ErrInvalidConfig`、`ErrInvalidMessage` 和 `ErrInvalidZapConfig`。 +- 非 2xx 响应返回 `*notice.HTTPError`。 +- 平台业务码失败返回 `*notice.PlatformError`。 +- `SendAsync` 的错误通过返回通道读取;Zap 通知错误通过 `ZapConfig.OnError` 接收。 + +```go +var platformErr *notice.PlatformError +if errors.As(err, &platformErr) { + log.Printf("channel=%s code=%s message=%s", platformErr.Channel, platformErr.Code, platformErr.Message) +} +``` + +配置中包含 Webhook 和签名密钥,业务代码不要将完整配置写入日志。 + +## 开发验证 + +```bash +go test -race ./... +``` + +详细的协议映射与实现说明见[多渠道消息通知平台设计文档](./多渠道消息通知平台设计文档.md)。 diff --git a/errors.go b/errors.go new file mode 100644 index 0000000..e3a9a95 --- /dev/null +++ b/errors.go @@ -0,0 +1,34 @@ +package notice + +import ( + "errors" + "fmt" +) + +var ( + ErrUnsupportedChannel = errors.New("notice: unsupported channel") + ErrChannelNotConfigured = errors.New("notice: channel not configured") + ErrInvalidConfig = errors.New("notice: invalid config") + ErrInvalidMessage = errors.New("notice: invalid message") + ErrInvalidZapConfig = errors.New("notice: invalid zap config") +) + +type HTTPError struct { + Channel Channel `json:"channel" dc:"消息渠道"` + StatusCode int `json:"status_code" dc:"HTTP 响应状态码"` + Body string `json:"body,omitempty" dc:"HTTP 响应正文"` +} + +func (e *HTTPError) Error() string { + return fmt.Sprintf("notice: %s webhook returned HTTP %d", e.Channel, e.StatusCode) +} + +type PlatformError struct { + Channel Channel `json:"channel" dc:"消息渠道"` + Code string `json:"code" dc:"平台业务错误码"` + Message string `json:"message" dc:"平台业务错误信息"` +} + +func (e *PlatformError) Error() string { + return fmt.Sprintf("notice: %s webhook failed: code=%s message=%s", e.Channel, e.Code, e.Message) +} diff --git a/factory.go b/factory.go new file mode 100644 index 0000000..f01150b --- /dev/null +++ b/factory.go @@ -0,0 +1,48 @@ +package notice + +import ( + "fmt" + "sync" +) + +type Factory struct { + mu sync.RWMutex `json:"-" dc:"实例缓存读写锁"` + configs map[Channel]Config `json:"-" dc:"按渠道保存的初始化配置"` + instances map[Channel]Sender `json:"-" dc:"按渠道缓存的 Sender 实例"` +} + +func NewFactory(configs map[Channel]Config) *Factory { + cloned := make(map[Channel]Config, len(configs)) + for channel, config := range configs { + cloned[channel] = config + } + return &Factory{configs: cloned, instances: make(map[Channel]Sender)} +} + +func (f *Factory) Get(channel Channel) (Sender, error) { + if f == nil { + return nil, fmt.Errorf("%w: factory is nil", ErrChannelNotConfigured) + } + f.mu.RLock() + instance, ok := f.instances[channel] + f.mu.RUnlock() + if ok { + return instance, nil + } + + f.mu.Lock() + defer f.mu.Unlock() + if instance, ok = f.instances[channel]; ok { + return instance, nil + } + config, ok := f.configs[channel] + if !ok { + return nil, fmt.Errorf("%w: %s", ErrChannelNotConfigured, channel) + } + instance, err := New(channel, config) + if err != nil { + return nil, err + } + f.instances[channel] = instance + return instance, nil +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..b78c582 --- /dev/null +++ b/go.mod @@ -0,0 +1,28 @@ +module git.zhangdeman.cn/zhangdeman/notice + +go 1.25.0 + +require ( + git.zhangdeman.cn/zhangdeman/network v0.0.0-20260719113820-2bc270960c64 + go.uber.org/zap v1.28.0 +) + +require ( + git.zhangdeman.cn/zhangdeman/consts v0.0.0-20260616025848-e4fb99612a4f // indirect + git.zhangdeman.cn/zhangdeman/dynamic-struct v0.0.0-20260524101316-06101dda00d0 // indirect + git.zhangdeman.cn/zhangdeman/op_type v0.0.0-20251013024601-da007da2fb42 // indirect + git.zhangdeman.cn/zhangdeman/serialize v0.0.0-20260112135254-c9ba29f9f674 // indirect + git.zhangdeman.cn/zhangdeman/util v0.0.0-20260105024213-3d76b1bcde5a // indirect + git.zhangdeman.cn/zhangdeman/wrapper v0.0.0-20260321023345-6c6e467e3a14 // indirect + github.com/BurntSushi/toml v1.6.0 // indirect + github.com/sbabiv/xml2map v1.2.1 // indirect + github.com/spaolacci/murmur3 v1.1.0 // indirect + github.com/tidwall/gjson v1.19.0 // indirect + github.com/tidwall/match v1.2.0 // indirect + github.com/tidwall/pretty v1.2.1 // indirect + go.uber.org/multierr v1.11.0 // indirect + golang.org/x/net v0.57.0 // indirect + gopkg.in/ini.v1 v1.67.3 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect + resty.dev/v3 v3.0.0-rc.3 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..927053f --- /dev/null +++ b/go.sum @@ -0,0 +1,74 @@ +git.zhangdeman.cn/zhangdeman/consts v0.0.0-20260616025848-e4fb99612a4f h1:H6Ggo/W9u2zumaa048gmZXnksiJMxu7TJFteSIqDjs4= +git.zhangdeman.cn/zhangdeman/consts v0.0.0-20260616025848-e4fb99612a4f/go.mod h1:5p8CEKGBxi7qPtTXDI3HDmqKAfIm5i/aBWdrbkbdNjc= +git.zhangdeman.cn/zhangdeman/dynamic-struct v0.0.0-20260524101316-06101dda00d0 h1:ReI0GxjjPT9eCC123QoLlIYmQWcWe7BRigfTnxAAH/k= +git.zhangdeman.cn/zhangdeman/dynamic-struct v0.0.0-20260524101316-06101dda00d0/go.mod h1:MqyNhsOug/4ku98nM/LeuX5fN9NePoW5IvXXKUUDt2w= +git.zhangdeman.cn/zhangdeman/network v0.0.0-20260719113820-2bc270960c64 h1:D8wpjDQ1ojwoGo72+fDaFzaoEKbUzmGJMyQ5jPgU3YU= +git.zhangdeman.cn/zhangdeman/network v0.0.0-20260719113820-2bc270960c64/go.mod h1:mLpwiIrIjOJTCuiBPWqE1VJGj4PkGu9ZojSc16ufbcY= +git.zhangdeman.cn/zhangdeman/op_type v0.0.0-20251013024601-da007da2fb42 h1:VjYrb4adud7FHeiYS9XA0B/tOaJjfRejzQAlwimrrDc= +git.zhangdeman.cn/zhangdeman/op_type v0.0.0-20251013024601-da007da2fb42/go.mod h1:VHb9qmhaPDAQDcS6vUiDCamYjZ4R5lD1XtVsh55KsMI= +git.zhangdeman.cn/zhangdeman/serialize v0.0.0-20260112135254-c9ba29f9f674 h1:75JJ09HPqWi9qm7XD+vV6p5TaCMQgDsae/EbsLiE1t4= +git.zhangdeman.cn/zhangdeman/serialize v0.0.0-20260112135254-c9ba29f9f674/go.mod h1:EXrvDs830GzqhDNTR5TgKVbT3ADRgyUb2pFerwF4rLc= +git.zhangdeman.cn/zhangdeman/util v0.0.0-20260105024213-3d76b1bcde5a h1:IGUsWz204BTQlD2l4kenlwJQS4Av2RS2kfUHZ5QVrmw= +git.zhangdeman.cn/zhangdeman/util v0.0.0-20260105024213-3d76b1bcde5a/go.mod h1:OKI+RVVfRTUf/ox9uDMydtos2YDFr+/UuU4FFZKgPYY= +git.zhangdeman.cn/zhangdeman/wrapper v0.0.0-20260321023345-6c6e467e3a14 h1:YtOciqKZVCtGt5YMMDEmINzRXK3ZE9mscXSkSKwK8/o= +git.zhangdeman.cn/zhangdeman/wrapper v0.0.0-20260321023345-6c6e467e3a14/go.mod h1:oSmLgHs4EausBSmH4GdpGUtjubPek8hLXTAUc4cPCb0= +github.com/BurntSushi/toml v1.6.0 h1:dRaEfpa2VI55EwlIW72hMRHdWouJeRF7TPYhI+AUQjk= +github.com/BurntSushi/toml v1.6.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/google/gofuzz v1.2.0 h1:xRy4A+RhZaiKjJ1bPfwQ8sedCA+YS2YcCHW6ec7JMi0= +github.com/google/gofuzz v1.2.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= +github.com/gopherjs/gopherjs v1.17.2 h1:fQnZVsXk8uxXIStYb0N4bGk7jeyTalG/wsZjQ25dO0g= +github.com/gopherjs/gopherjs v1.17.2/go.mod h1:pRRIvn/QzFLrKfvEz3qUuEhtE/zLCWfreZ6J5gM2i+k= +github.com/jtolds/gls v4.20.0+incompatible h1:xdiiI2gbIgH/gLH7ADydsJ1uDOEzR8yvV7C0MuV77Wo= +github.com/jtolds/gls v4.20.0+incompatible/go.mod h1:QJZ7F/aHp+rZTRtaJ1ow/lLfFfVYBRgL+9YlvaHOwJU= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e h1:fD57ERR4JtEqsWbfPhv4DMiApHyliiK5xCTNVSPiaAs= +github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/sbabiv/xml2map v1.2.1 h1:1lT7t0hhUvXZCkdxqtq4n8/ZCnwLWGq4rDuDv5XOoFE= +github.com/sbabiv/xml2map v1.2.1/go.mod h1:2TPoAfcaM7+Sd4iriPvzyntb2mx7GY+kkQpB/GQa/eo= +github.com/smarty/assertions v1.15.0 h1:cR//PqUBUiQRakZWqBiFFQ9wb8emQGDb0HeGdqGByCY= +github.com/smarty/assertions v1.15.0/go.mod h1:yABtdzeQs6l1brC900WlRNwj6ZR55d7B+E8C6HtKdec= +github.com/smartystreets/goconvey v1.8.1 h1:qGjIddxOk4grTu9JPOU31tVfq3cNdBlNa5sSznIX1xY= +github.com/smartystreets/goconvey v1.8.1/go.mod h1:+/u4qLyY6x1jReYOp7GOM2FSt8aP9CzCZL03bI28W60= +github.com/spaolacci/murmur3 v1.1.0 h1:7c1g84S4BPRrfL5Xrdp6fOJ206sU9y293DDHaoy0bLI= +github.com/spaolacci/murmur3 v1.1.0/go.mod h1:JwIasOWyU6f++ZhiEuf87xNszmSA2myDM2Kzu9HwQUA= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= +github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= +github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= +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.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/tidwall/gjson v1.19.0 h1:xwxm7n691Uf3u5OFjzngavjGTh55KX5q/9w9xHW88JU= +github.com/tidwall/gjson v1.19.0/go.mod h1:V37/opeE/JbLUOfH0QTXiNez2l0RUjYUhpT4szFQAfc= +github.com/tidwall/match v1.2.0 h1:0pt8FlkOwjN2fPt4bIl4BoNxb98gGHN2ObFEDkrfZnM= +github.com/tidwall/match v1.2.0/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM= +github.com/tidwall/pretty v1.2.1 h1:qjsOFOWWQl+N3RsoF5/ssm1pHmJJwhjlSbZ51I6wMl4= +github.com/tidwall/pretty v1.2.1/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU= +go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= +go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= +go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= +go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= +go.uber.org/zap v1.28.0 h1:IZzaP1Fv73/T/pBMLk4VutPl36uNC+OSUh3JLG3FIjo= +go.uber.org/zap v1.28.0/go.mod h1:rDLpOi171uODNm/mxFcuYWxDsqWSAVkFdX4XojSKg/Q= +go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= +go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= +golang.org/x/net v0.57.0 h1:K5+3DljvIuDG9/Jv9rvyMywYNFCQ9RSUY6OOTTkT+tE= +golang.org/x/net v0.57.0/go.mod h1:KpXc8iv+r3XplLAG/f7Jsf9RPszJzdR0f58q9vGOuEU= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f h1:BLraFXnmrev5lT+xlilqcH8XK9/i0At2xKjWk4p6zsU= +gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/ini.v1 v1.67.3 h1:iM9Lhz5MRSGhHVGGwCuzG9KO8PoirCXj/m/qTmOJJQw= +gopkg.in/ini.v1 v1.67.3/go.mod h1:x/cyOwCgZqOkJoDIJ3c1KNHMo10+nLGAhh+kn3Zizss= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +resty.dev/v3 v3.0.0-rc.3 h1:k24LZ03Cb4Ue5e6O/Pfxu5TQRBBYGES6wm2wceia+Io= +resty.dev/v3 v3.0.0-rc.3/go.mod h1:NTOerrC/4T7/FE6tXIZGIysXXBdgNqwMZuKtxpea9NM= diff --git a/message.go b/message.go new file mode 100644 index 0000000..36e7b02 --- /dev/null +++ b/message.go @@ -0,0 +1,88 @@ +package notice + +import ( + "fmt" + "net/url" + "strings" +) + +func Text(content string) Message { + return Message{Type: MessageText, Content: content} +} + +func ColorText(segments ...TextSegment) Message { + return Message{Type: MessageColorText, Segments: segments} +} + +func Markdown(title, content string) Message { + return Message{Type: MessageMarkdown, Title: title, Content: content} +} + +func Card(content CardContent) Message { + return Message{Type: MessageCard, Card: &content} +} + +func validateMessage(message Message) error { + switch message.Type { + case MessageText: + if strings.TrimSpace(message.Content) == "" { + return fmt.Errorf("%w: text content is empty", ErrInvalidMessage) + } + case MessageMarkdown: + if strings.TrimSpace(message.Content) == "" { + return fmt.Errorf("%w: markdown content is empty", ErrInvalidMessage) + } + case MessageColorText: + nonEmpty := false + for _, segment := range message.Segments { + if strings.TrimSpace(segment.Text) != "" { + nonEmpty = true + } + if !validColor(segment.Color) { + return fmt.Errorf("%w: unsupported color %q", ErrInvalidMessage, segment.Color) + } + } + if !nonEmpty { + return fmt.Errorf("%w: color text segments are empty", ErrInvalidMessage) + } + case MessageCard: + if message.Card == nil { + return fmt.Errorf("%w: card content is nil", ErrInvalidMessage) + } + if strings.TrimSpace(message.Card.Title) == "" && strings.TrimSpace(message.Card.Markdown) == "" { + return fmt.Errorf("%w: card title and markdown are empty", ErrInvalidMessage) + } + if !validTheme(message.Card.Theme) { + return fmt.Errorf("%w: unsupported card theme %q", ErrInvalidMessage, message.Card.Theme) + } + for _, action := range message.Card.Actions { + parsed, err := url.ParseRequestURI(strings.TrimSpace(action.URL)) + if strings.TrimSpace(action.Text) == "" || err != nil || parsed.Scheme != "https" || parsed.Host == "" { + return fmt.Errorf("%w: card action requires text and HTTPS URL", ErrInvalidMessage) + } + } + default: + return fmt.Errorf("%w: unsupported message type %q", ErrInvalidMessage, message.Type) + } + return nil +} + +func validColor(color Color) bool { + switch color { + case "", ColorDefault, ColorInfo, ColorSuccess, ColorWarning, ColorDanger, ColorMuted: + return true + default: + return false + } +} + +func validTheme(theme CardTheme) bool { + switch theme { + case "", CardThemeDefault, CardThemeBlue, CardThemeWathet, CardThemeTurquoise, CardThemeGreen, + CardThemeYellow, CardThemeOrange, CardThemeRed, CardThemeCarmine, CardThemeViolet, + CardThemePurple, CardThemeIndigo, CardThemeGrey: + return true + default: + return false + } +} diff --git a/notice_test.go b/notice_test.go new file mode 100644 index 0000000..c24e168 --- /dev/null +++ b/notice_test.go @@ -0,0 +1,278 @@ +package notice + +import ( + "context" + "encoding/json" + "errors" + "reflect" + "strings" + "sync" + "testing" + "time" + + "go.uber.org/zap" + "go.uber.org/zap/zapcore" +) + +type stubNetworkClient struct { + mu sync.Mutex `json:"-" dc:"测试请求记录锁"` + requests []networkRequest `json:"-" dc:"测试请求记录"` + response networkResponse `json:"-" dc:"测试响应"` + err error `json:"-" dc:"测试错误"` +} + +func (c *stubNetworkClient) Post(request networkRequest) (networkResponse, error) { + c.mu.Lock() + defer c.mu.Unlock() + c.requests = append(c.requests, request) + return c.response, c.err +} + +type stubSender struct { + mu sync.Mutex `json:"-" dc:"测试消息记录锁"` + messages []Message `json:"-" dc:"测试消息记录"` + err error `json:"-" dc:"测试发送错误"` +} + +func (s *stubSender) Send(_ context.Context, message Message) error { + s.mu.Lock() + defer s.mu.Unlock() + s.messages = append(s.messages, message) + return s.err +} + +func (s *stubSender) SendAsync(ctx context.Context, message Message) <-chan error { + result := make(chan error, 1) + result <- s.Send(ctx, message) + close(result) + return result +} + +func TestRenderersRenderSupportedMessages(t *testing.T) { + messages := []Message{ + Text("hello"), + ColorText(TextSegment{Text: "failed", Color: ColorDanger, Bold: true}), + Markdown("alert", "**failed**"), + Card(CardContent{ + Title: "release", Theme: CardThemeGreen, Markdown: "done", + Fields: []CardField{{Name: "service", Value: "order"}}, + Actions: []CardAction{{Text: "details", URL: "https://example.com/releases/1"}}, + }), + } + tests := []struct { + Name string `json:"name" dc:"测试名称"` + Channel Channel `json:"channel" dc:"测试渠道"` + Renderer renderer `json:"-" dc:"待测试 Renderer"` + }{ + {Name: "feishu", Channel: ChannelFeishu, Renderer: feishuRenderer{}}, + {Name: "dingtalk", Channel: ChannelDingTalk, Renderer: dingTalkRenderer{}}, + {Name: "wecom", Channel: ChannelWeCom, Renderer: weComRenderer{}}, + {Name: "webhook", Channel: ChannelWebhook, Renderer: webhookRenderer{}}, + } + for _, test := range tests { + t.Run(test.Name, func(t *testing.T) { + for _, message := range messages { + if err := validateMessage(message); err != nil { + t.Fatalf("valid message rejected: %v", err) + } + request, err := test.Renderer.Render(Config{Webhook: "https://example.com/hook"}, message) + if err != nil { + t.Fatalf("render %s: %v", message.Type, err) + } + if !json.Valid(request.Body) { + t.Fatalf("rendered body is not JSON: %s", request.Body) + } + } + }) + } +} + +func TestFeishuCardThemeAndDingTalkSignature(t *testing.T) { + request, err := (feishuRenderer{}).Render(Config{Webhook: "https://example.com/hook"}, Card(CardContent{ + Title: "release", Theme: CardThemeViolet, Markdown: "done", + Actions: []CardAction{{Text: "details", URL: "https://example.com/releases/1"}}, + })) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(string(request.Body), `"template":"violet"`) { + t.Fatalf("feishu theme missing: %s", request.Body) + } + if strings.Contains(string(request.Body), `"tag":"action"`) || !strings.Contains(string(request.Body), `"tag":"button"`) { + t.Fatalf("feishu Card JSON 2.0 button structure is invalid: %s", request.Body) + } + + dingRequest, err := (dingTalkRenderer{}).Render(Config{ + Webhook: "https://example.com/hook?access_token=x", + Security: &SecurityConfig{SignEnabled: true, SignSecret: "test-secret"}, + }, Text("hello")) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(dingRequest.URL, "timestamp=") || !strings.Contains(dingRequest.URL, "sign=") { + t.Fatalf("dingtalk signature query missing: %s", dingRequest.URL) + } +} + +func TestIndependentSignatureSecurity(t *testing.T) { + feishuConfig := Config{ + Webhook: "https://example.com/feishu", + Security: &SecurityConfig{SignEnabled: true, SignSecret: "feishu-test-secret"}, + } + request, err := (feishuRenderer{}).Render(feishuConfig, Text("hello")) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(string(request.Body), `"timestamp"`) || !strings.Contains(string(request.Body), `"sign"`) { + t.Fatalf("feishu signature fields missing: %s", request.Body) + } + + unsigned, err := (feishuRenderer{}).Render(Config{ + Webhook: "https://example.com/feishu", + Security: &SecurityConfig{SignEnabled: false, SignSecret: "ignored-secret"}, + }, Text("hello")) + if err != nil { + t.Fatal(err) + } + if strings.Contains(string(unsigned.Body), `"sign"`) || strings.Contains(string(unsigned.Body), `"timestamp"`) { + t.Fatalf("disabled security unexpectedly signed request: %s", unsigned.Body) + } + + if _, err = New(ChannelFeishu, Config{ + Webhook: "https://example.com/feishu", + Security: &SecurityConfig{SignEnabled: true}, + }); !errors.Is(err, ErrInvalidConfig) { + t.Fatalf("expected empty signature secret validation error, got %v", err) + } + if _, err = New(ChannelWeCom, Config{ + Webhook: "https://example.com/wecom", + Security: &SecurityConfig{SignEnabled: true, SignSecret: "unsupported"}, + }); !errors.Is(err, ErrInvalidConfig) { + t.Fatalf("expected unsupported signature validation error, got %v", err) + } +} + +func TestSenderSyncAsyncAndPlatformError(t *testing.T) { + client := &stubNetworkClient{response: networkResponse{StatusCode: 200, Body: []byte(`{"errcode":0,"errmsg":"ok"}`)}} + sender, err := newSender(ChannelWeCom, Config{Webhook: "https://example.com/hook"}, client) + if err != nil { + t.Fatal(err) + } + if err = sender.Send(context.Background(), Text("hello")); err != nil { + t.Fatalf("sync send: %v", err) + } + if err = <-sender.SendAsync(context.Background(), Markdown("title", "body")); err != nil { + t.Fatalf("async send: %v", err) + } + if len(client.requests) != 2 { + t.Fatalf("got %d requests, want 2", len(client.requests)) + } + + client.response.Body = []byte(`{"errcode":93000,"errmsg":"invalid webhook"}`) + err = sender.Send(context.Background(), Text("hello")) + var platformErr *PlatformError + if !errors.As(err, &platformErr) || platformErr.Code != "93000" { + t.Fatalf("expected PlatformError, got %v", err) + } +} + +func TestFactoryCachesInstanceConcurrently(t *testing.T) { + factory := NewFactory(map[Channel]Config{ChannelWebhook: {Webhook: "https://example.com/hook"}}) + const workers = 32 + instances := make(chan Sender, workers) + var group sync.WaitGroup + for range workers { + group.Add(1) + go func() { + defer group.Done() + instance, err := factory.Get(ChannelWebhook) + if err != nil { + t.Errorf("factory get: %v", err) + return + } + instances <- instance + }() + } + group.Wait() + close(instances) + var first Sender + for instance := range instances { + if first == nil { + first = instance + continue + } + if first != instance { + t.Fatal("factory returned different instances for one channel") + } + } +} + +func TestZapCoreDefaultExactLevelsAndSync(t *testing.T) { + sender := &stubSender{} + _, levels, err := normalizeZapConfig(ZapConfig{Channels: []Channel{ChannelWebhook}}) + if err != nil { + t.Fatal(err) + } + noticeCore := &zapNoticeCore{ + senders: []Sender{sender}, levels: levels, pending: &sync.WaitGroup{}, + } + base := zap.NewNop() + logger := base.WithOptions(zap.WrapCore(func(original zapcore.Core) zapcore.Core { + return zapcore.NewTee(original, noticeCore) + })).With(zap.String("service", "order")) + logger.Info("started") + logger.Warn("slow", zap.Int("delay_ms", 2500)) + logger.Error("failed") + if err := logger.Sync(); err != nil { + t.Fatal(err) + } + if len(sender.messages) != 2 { + t.Fatalf("got %d messages, want warn and error only", len(sender.messages)) + } + themes := map[CardTheme]int{} + for _, message := range sender.messages { + if message.Card == nil { + t.Fatalf("unexpected non-card Zap message: %#v", message) + } + themes[message.Card.Theme]++ + } + if themes[CardThemeOrange] != 1 || themes[CardThemeRed] != 1 { + t.Fatalf("unexpected Zap card themes: %#v", themes) + } +} + +func TestAllStructFieldsHaveJSONAndDCTags(t *testing.T) { + types := []reflect.Type{ + reflect.TypeOf(Message{}), reflect.TypeOf(TextSegment{}), reflect.TypeOf(CardContent{}), + reflect.TypeOf(CardField{}), reflect.TypeOf(CardAction{}), reflect.TypeOf(Config{}), + reflect.TypeOf(SecurityConfig{}), + reflect.TypeOf(ZapConfig{}), reflect.TypeOf(Capabilities{}), reflect.TypeOf(HTTPError{}), + reflect.TypeOf(PlatformError{}), reflect.TypeOf(Factory{}), reflect.TypeOf(channelSender{}), + reflect.TypeOf(networkRequest{}), reflect.TypeOf(networkResponse{}), reflect.TypeOf(renderedRequest{}), + reflect.TypeOf(zapNoticeCore{}), + } + for _, typ := range types { + for index := 0; index < typ.NumField(); index++ { + field := typ.Field(index) + if _, exists := field.Tag.Lookup("json"); !exists { + t.Errorf("%s.%s has no json tag", typ.Name(), field.Name) + } + if value, exists := field.Tag.Lookup("dc"); !exists || strings.TrimSpace(value) == "" { + t.Errorf("%s.%s has no dc tag", typ.Name(), field.Name) + } + } + } +} + +func TestContextAlreadyCancelled(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + cancel() + client := &stubNetworkClient{err: context.Canceled} + sender, err := newSender(ChannelWebhook, Config{Webhook: "https://example.com/hook", Timeout: time.Second}, client) + if err != nil { + t.Fatal(err) + } + if err = sender.Send(ctx, Text("hello")); !errors.Is(err, context.Canceled) { + t.Fatalf("expected context cancellation, got %v", err) + } +} diff --git a/renderer.go b/renderer.go new file mode 100644 index 0000000..1b03c8c --- /dev/null +++ b/renderer.go @@ -0,0 +1,69 @@ +package notice + +import ( + "encoding/json" + "fmt" + "html" + "strings" +) + +type renderedRequest struct { + URL string `json:"url" dc:"最终请求地址"` + Body []byte `json:"body" dc:"平台请求体"` +} + +type renderer interface { + Capabilities() Capabilities + Render(config Config, message Message) (renderedRequest, error) + ValidateResponse(response networkResponse) error +} + +func marshalRequest(url string, body any) (renderedRequest, error) { + data, err := json.Marshal(body) + if err != nil { + return renderedRequest{}, fmt.Errorf("notice: marshal webhook request: %w", err) + } + return renderedRequest{URL: url, Body: data}, nil +} + +func validateHTTP(channel Channel, response networkResponse) error { + if response.StatusCode < 200 || response.StatusCode >= 300 { + return &HTTPError{Channel: channel, StatusCode: response.StatusCode, Body: string(response.Body)} + } + return nil +} + +func cardMarkdown(card *CardContent) string { + parts := make([]string, 0, 1+len(card.Fields)) + if strings.TrimSpace(card.Markdown) != "" { + parts = append(parts, card.Markdown) + } + for _, field := range card.Fields { + parts = append(parts, fmt.Sprintf("**%s:** %s", field.Name, field.Value)) + } + return strings.Join(parts, "\n\n") +} + +func cardMarkdownFallback(card *CardContent) string { + content := cardMarkdown(card) + if strings.TrimSpace(content) == "" { + return card.Title + } + if strings.TrimSpace(card.Title) == "" { + return content + } + return "## " + card.Title + "\n\n" + content +} + +func plainTitle(title, fallback string) string { + if strings.TrimSpace(title) == "" { + return fallback + } + return title +} + +func escapeText(value string) string { + value = html.EscapeString(value) + replacer := strings.NewReplacer("\\", "\\\\", "*", "\\*", "_", "\\_", "`", "\\`", "[", "\\[", "]", "\\]") + return replacer.Replace(value) +} diff --git a/renderer_dingtalk.go b/renderer_dingtalk.go new file mode 100644 index 0000000..9a8c57b --- /dev/null +++ b/renderer_dingtalk.go @@ -0,0 +1,105 @@ +package notice + +import ( + "crypto/hmac" + "crypto/sha256" + "encoding/base64" + "fmt" + "net/url" + "strconv" + "strings" + "time" +) + +type dingTalkRenderer struct{} + +func (dingTalkRenderer) Capabilities() Capabilities { + return Capabilities{Text: true, ColorText: true, Markdown: true, Card: true} +} + +func (dingTalkRenderer) Render(config Config, message Message) (renderedRequest, error) { + body := map[string]any{} + switch message.Type { + case MessageText: + body = map[string]any{"msgtype": "text", "text": map[string]any{"content": message.Content}} + case MessageColorText: + body = dingTalkMarkdown("消息通知", dingTalkColorMarkdown(message.Segments)) + case MessageMarkdown: + body = dingTalkMarkdown(plainTitle(message.Title, "消息通知"), message.Content) + case MessageCard: + content := cardMarkdown(message.Card) + if strings.TrimSpace(content) == "" { + content = message.Card.Title + } + if len(message.Card.Actions) == 0 { + body = dingTalkMarkdown(plainTitle(message.Card.Title, "消息通知"), content) + break + } + buttons := make([]any, 0, len(message.Card.Actions)) + for _, action := range message.Card.Actions { + buttons = append(buttons, map[string]any{"title": action.Text, "actionURL": action.URL}) + } + body = map[string]any{"msgtype": "actionCard", "actionCard": map[string]any{ + "title": plainTitle(message.Card.Title, "消息通知"), "text": content, + "btnOrientation": "0", "btns": buttons, + }} + } + webhook := config.Webhook + secret := signatureSecret(config) + if secret != "" { + timestamp := strconv.FormatInt(time.Now().UnixMilli(), 10) + sign := dingTalkSign(timestamp, secret) + parsed, err := url.Parse(webhook) + if err != nil { + return renderedRequest{}, err + } + query := parsed.Query() + query.Set("timestamp", timestamp) + query.Set("sign", sign) + parsed.RawQuery = query.Encode() + webhook = parsed.String() + } + return marshalRequest(webhook, body) +} + +func (dingTalkRenderer) ValidateResponse(response networkResponse) error { + if err := validateHTTP(ChannelDingTalk, response); err != nil { + return err + } + code, message, err := responseStatus(response.Body, "errcode", "", "errmsg", "") + if err != nil { + return fmt.Errorf("notice: parse dingtalk response: %w", err) + } + if code != "0" { + return &PlatformError{Channel: ChannelDingTalk, Code: code, Message: message} + } + return nil +} + +func dingTalkSign(timestamp, secret string) string { + digest := hmac.New(sha256.New, []byte(secret)) + _, _ = digest.Write([]byte(timestamp + "\n" + secret)) + return base64.StdEncoding.EncodeToString(digest.Sum(nil)) +} + +func dingTalkMarkdown(title, content string) map[string]any { + return map[string]any{"msgtype": "markdown", "markdown": map[string]any{"title": title, "text": content}} +} + +func dingTalkColorMarkdown(segments []TextSegment) string { + var builder strings.Builder + for _, segment := range segments { + prefix := map[Color]string{ + ColorInfo: "【提示】", ColorSuccess: "【成功】", ColorWarning: "【警告】", ColorDanger: "【异常】", + }[segment.Color] + content := escapeText(segment.Text) + if prefix != "" { + content = prefix + content + } + if segment.Bold || segment.Color == ColorWarning || segment.Color == ColorDanger { + content = "**" + content + "**" + } + builder.WriteString(content) + } + return builder.String() +} diff --git a/renderer_feishu.go b/renderer_feishu.go new file mode 100644 index 0000000..9e3632e --- /dev/null +++ b/renderer_feishu.go @@ -0,0 +1,146 @@ +package notice + +import ( + "crypto/hmac" + "crypto/sha256" + "encoding/base64" + "encoding/json" + "fmt" + "strconv" + "strings" + "time" +) + +type feishuRenderer struct{} + +func (feishuRenderer) Capabilities() Capabilities { + return Capabilities{Text: true, ColorText: true, Markdown: true, Card: true} +} + +func (feishuRenderer) Render(config Config, message Message) (renderedRequest, error) { + body := map[string]any{} + switch message.Type { + case MessageText: + body["msg_type"] = "text" + body["content"] = map[string]any{"text": message.Content} + case MessageColorText: + body["msg_type"] = "interactive" + body["card"] = feishuCard("", CardThemeDefault, feishuColorMarkdown(message.Segments), nil, nil) + case MessageMarkdown: + body["msg_type"] = "interactive" + body["card"] = feishuCard(message.Title, CardThemeDefault, message.Content, nil, nil) + case MessageCard: + body["msg_type"] = "interactive" + body["card"] = feishuCard(message.Card.Title, message.Card.Theme, message.Card.Markdown, message.Card.Fields, message.Card.Actions) + } + + secret := signatureSecret(config) + if secret != "" { + timestamp := strconv.FormatInt(time.Now().Unix(), 10) + body["timestamp"] = timestamp + body["sign"] = feishuSign(timestamp, secret) + } + return marshalRequest(config.Webhook, body) +} + +func (feishuRenderer) ValidateResponse(response networkResponse) error { + if err := validateHTTP(ChannelFeishu, response); err != nil { + return err + } + code, message, err := responseStatus(response.Body, "code", "StatusCode", "msg", "StatusMessage") + if err != nil { + return fmt.Errorf("notice: parse feishu response: %w", err) + } + if code != "0" { + return &PlatformError{Channel: ChannelFeishu, Code: code, Message: message} + } + return nil +} + +func feishuSign(timestamp, secret string) string { + key := []byte(timestamp + "\n" + secret) + digest := hmac.New(sha256.New, key) + return base64.StdEncoding.EncodeToString(digest.Sum(nil)) +} + +func feishuColorMarkdown(segments []TextSegment) string { + var builder strings.Builder + for _, segment := range segments { + content := escapeText(segment.Text) + if segment.Bold { + content = "**" + content + "**" + } + color := map[Color]string{ + ColorInfo: "blue", ColorSuccess: "green", ColorWarning: "orange", + ColorDanger: "red", ColorMuted: "grey", + }[segment.Color] + if color != "" { + content = `` + content + `` + } + builder.WriteString(content) + } + return builder.String() +} + +func feishuCard(title string, theme CardTheme, markdown string, fields []CardField, actions []CardAction) map[string]any { + if theme == "" { + theme = CardThemeDefault + } + elements := make([]any, 0, 2) + content := strings.TrimSpace(markdown) + if len(fields) > 0 { + content = strings.TrimSpace(cardMarkdown(&CardContent{Markdown: markdown, Fields: fields})) + } + if content != "" { + elements = append(elements, map[string]any{"tag": "markdown", "content": content}) + } + if len(actions) > 0 { + for _, action := range actions { + elements = append(elements, map[string]any{ + "tag": "button", + "text": map[string]any{"tag": "plain_text", "content": action.Text}, + "type": "default", + "behaviors": []any{map[string]any{"type": "open_url", "default_url": action.URL}}, + }) + } + } + card := map[string]any{ + "schema": "2.0", + "body": map[string]any{"direction": "vertical", "elements": elements}, + } + if strings.TrimSpace(title) == "" && theme != CardThemeDefault { + title = "消息通知" + } + if strings.TrimSpace(title) != "" { + card["header"] = map[string]any{ + "title": map[string]any{"tag": "plain_text", "content": title}, + "template": string(theme), + } + } + return card +} + +func responseStatus(body []byte, codeKey, alternateCodeKey, messageKey, alternateMessageKey string) (string, string, error) { + var payload map[string]json.RawMessage + if err := json.Unmarshal(body, &payload); err != nil { + return "", "", err + } + codeRaw := payload[codeKey] + if len(codeRaw) == 0 { + codeRaw = payload[alternateCodeKey] + } + if len(codeRaw) == 0 { + return "", "", fmt.Errorf("business code is missing") + } + var code any + if err := json.Unmarshal(codeRaw, &code); err != nil { + return "", "", err + } + messageRaw := payload[messageKey] + if len(messageRaw) == 0 { + messageRaw = payload[alternateMessageKey] + } + var message string + _ = json.Unmarshal(messageRaw, &message) + return fmt.Sprint(code), message, nil +} diff --git a/renderer_webhook.go b/renderer_webhook.go new file mode 100644 index 0000000..3b39d12 --- /dev/null +++ b/renderer_webhook.go @@ -0,0 +1,15 @@ +package notice + +type webhookRenderer struct{} + +func (webhookRenderer) Capabilities() Capabilities { + return Capabilities{Text: true, ColorText: true, Markdown: true, Card: true} +} + +func (webhookRenderer) Render(config Config, message Message) (renderedRequest, error) { + return marshalRequest(config.Webhook, message) +} + +func (webhookRenderer) ValidateResponse(response networkResponse) error { + return validateHTTP(ChannelWebhook, response) +} diff --git a/renderer_wecom.go b/renderer_wecom.go new file mode 100644 index 0000000..f3e9929 --- /dev/null +++ b/renderer_wecom.go @@ -0,0 +1,83 @@ +package notice + +import ( + "fmt" + "strings" +) + +type weComRenderer struct{} + +func (weComRenderer) Capabilities() Capabilities { + return Capabilities{Text: true, ColorText: true, Markdown: true, Card: true} +} + +func (weComRenderer) Render(config Config, message Message) (renderedRequest, error) { + body := map[string]any{} + switch message.Type { + case MessageText: + body = map[string]any{"msgtype": "text", "text": map[string]any{"content": message.Content}} + case MessageColorText: + body = weComMarkdown(weComColorMarkdown(message.Segments)) + case MessageMarkdown: + body = weComMarkdown(message.Content) + case MessageCard: + if len(message.Card.Actions) == 0 { + body = weComMarkdown(cardMarkdownFallback(message.Card)) + break + } + horizontal := make([]any, 0, len(message.Card.Fields)) + for _, field := range message.Card.Fields { + horizontal = append(horizontal, map[string]any{"keyname": field.Name, "value": field.Value}) + } + jumps := make([]any, 0, len(message.Card.Actions)) + for _, action := range message.Card.Actions { + jumps = append(jumps, map[string]any{"type": 1, "title": action.Text, "url": action.URL}) + } + first := message.Card.Actions[0] + body = map[string]any{"msgtype": "template_card", "template_card": map[string]any{ + "card_type": "text_notice", + "main_title": map[string]any{"title": plainTitle(message.Card.Title, "消息通知"), "desc": ""}, + "sub_title_text": message.Card.Markdown, + "horizontal_content_list": horizontal, + "jump_list": jumps, + "card_action": map[string]any{"type": 1, "url": first.URL}, + }} + } + return marshalRequest(config.Webhook, body) +} + +func (weComRenderer) ValidateResponse(response networkResponse) error { + if err := validateHTTP(ChannelWeCom, response); err != nil { + return err + } + code, message, err := responseStatus(response.Body, "errcode", "", "errmsg", "") + if err != nil { + return fmt.Errorf("notice: parse wecom response: %w", err) + } + if code != "0" { + return &PlatformError{Channel: ChannelWeCom, Code: code, Message: message} + } + return nil +} + +func weComMarkdown(content string) map[string]any { + return map[string]any{"msgtype": "markdown", "markdown": map[string]any{"content": content}} +} + +func weComColorMarkdown(segments []TextSegment) string { + var builder strings.Builder + for _, segment := range segments { + content := escapeText(segment.Text) + if segment.Bold { + content = "**" + content + "**" + } + color := map[Color]string{ + ColorInfo: "info", ColorSuccess: "info", ColorWarning: "warning", ColorDanger: "warning", ColorMuted: "comment", + }[segment.Color] + if color != "" { + content = `` + content + `` + } + builder.WriteString(content) + } + return builder.String() +} diff --git a/sender.go b/sender.go new file mode 100644 index 0000000..2a45210 --- /dev/null +++ b/sender.go @@ -0,0 +1,115 @@ +package notice + +import ( + "context" + "fmt" + "net/url" + "strings" + "time" +) + +const defaultTimeout = 5 * time.Second + +type channelSender struct { + channel Channel `json:"-" dc:"当前消息渠道"` + renderer renderer `json:"-" dc:"渠道消息渲染器"` + client networkClient `json:"-" dc:"HTTP 网络请求客户端"` + config Config `json:"-" dc:"当前渠道配置"` +} + +func New(channel Channel, config Config) (Sender, error) { + return newSender(channel, config, defaultNetworkClient{}) +} + +func newSender(channel Channel, config Config, client networkClient) (Sender, error) { + if err := validateConfig(channel, config); err != nil { + return nil, err + } + if config.Timeout <= 0 { + config.Timeout = defaultTimeout + } + selected, err := rendererFor(channel) + if err != nil { + return nil, err + } + return &channelSender{channel: channel, renderer: selected, client: client, config: config}, nil +} + +func (s *channelSender) Send(ctx context.Context, message Message) error { + if err := validateMessage(message); err != nil { + return err + } + request, err := s.renderer.Render(s.config, message) + if err != nil { + return err + } + response, err := s.client.Post(networkRequest{ + Context: ctx, + URL: request.URL, + Body: request.Body, + Timeout: s.config.Timeout, + }) + if err != nil { + return fmt.Errorf("notice: send %s webhook: %w", s.channel, err) + } + return s.renderer.ValidateResponse(response) +} + +func (s *channelSender) SendAsync(ctx context.Context, message Message) <-chan error { + result := make(chan error, 1) + go func() { + defer close(result) + result <- s.Send(ctx, message) + }() + return result +} + +func validateConfig(channel Channel, config Config) error { + if !validChannel(channel) { + return fmt.Errorf("%w: %q", ErrUnsupportedChannel, channel) + } + parsed, err := url.ParseRequestURI(strings.TrimSpace(config.Webhook)) + if err != nil || parsed.Scheme != "https" || parsed.Host == "" { + return fmt.Errorf("%w: webhook must be a valid HTTPS URL", ErrInvalidConfig) + } + if config.Security != nil && config.Security.SignEnabled { + if channel != ChannelFeishu && channel != ChannelDingTalk { + return fmt.Errorf("%w: channel %s does not support independent signature verification", ErrInvalidConfig, channel) + } + if strings.TrimSpace(config.Security.SignSecret) == "" { + return fmt.Errorf("%w: signature secret is empty", ErrInvalidConfig) + } + } + return nil +} + +func signatureSecret(config Config) string { + if config.Security == nil || !config.Security.SignEnabled { + return "" + } + return strings.TrimSpace(config.Security.SignSecret) +} + +func validChannel(channel Channel) bool { + switch channel { + case ChannelFeishu, ChannelDingTalk, ChannelWeCom, ChannelWebhook: + return true + default: + return false + } +} + +func rendererFor(channel Channel) (renderer, error) { + switch channel { + case ChannelFeishu: + return feishuRenderer{}, nil + case ChannelDingTalk: + return dingTalkRenderer{}, nil + case ChannelWeCom: + return weComRenderer{}, nil + case ChannelWebhook: + return webhookRenderer{}, nil + default: + return nil, fmt.Errorf("%w: %q", ErrUnsupportedChannel, channel) + } +} diff --git a/transport.go b/transport.go new file mode 100644 index 0000000..ef5f59a --- /dev/null +++ b/transport.go @@ -0,0 +1,79 @@ +package notice + +import ( + "context" + "errors" + "net/http" + "time" + + "git.zhangdeman.cn/zhangdeman/network/httpclient" + "git.zhangdeman.cn/zhangdeman/network/httpclient/define" +) + +type networkRequest struct { + Context context.Context `json:"-" dc:"请求上下文"` + URL string `json:"url" dc:"请求地址"` + Body []byte `json:"body" dc:"JSON 请求体"` + Timeout time.Duration `json:"timeout" dc:"请求超时时间"` +} + +type networkResponse struct { + StatusCode int `json:"status_code" dc:"HTTP 响应状态码"` + Body []byte `json:"body" dc:"HTTP 响应正文"` +} + +type networkClient interface { + Post(request networkRequest) (networkResponse, error) +} + +type defaultNetworkClient struct{} + +func (defaultNetworkClient) Post(request networkRequest) (networkResponse, error) { + ctx := request.Context + if ctx == nil { + ctx = context.Background() + } + if err := ctx.Err(); err != nil { + return networkResponse{}, err + } + timeout := request.Timeout + if timeout <= 0 { + timeout = defaultTimeout + } + + client, err := httpclient.NewHttpClient(&define.Request{ + Ctx: ctx, + Body: request.Body, + Header: map[string]any{"Content-Type": "application/json"}, + FullUrl: request.URL, + ContentType: "application/json", + Method: http.MethodPost, + DataField: "BODY_ROOT", + SuccessHttpCodeList: nil, + SuccessCodeList: nil, + ConnectTimeout: timeout.Milliseconds(), + ReadTimeout: timeout.Milliseconds(), + RetryRule: &define.RequestRetryRule{ + RetryCount: 0, + RetryTimeInterval: 0, + RetryHttpCodeList: []int64{}, + RetryBusinessCodeList: []string{}, + }, + }, nil) + if err != nil { + return networkResponse{}, err + } + + response := client.Request() + if response == nil { + return networkResponse{}, errors.New("notice: network package returned nil response") + } + body := []byte(response.Data) + if response.RestyResponse != nil { + body = append([]byte(nil), response.RestyResponse.Bytes()...) + } + if response.RestyResponse == nil && response.FailInfo != nil { + return networkResponse{}, errors.New(response.FailInfo.Message) + } + return networkResponse{StatusCode: response.HttpCode, Body: body}, nil +} diff --git a/types.go b/types.go new file mode 100644 index 0000000..76dd199 --- /dev/null +++ b/types.go @@ -0,0 +1,116 @@ +package notice + +import ( + "context" + "time" + + "go.uber.org/zap/zapcore" +) + +type Channel string + +const ( + ChannelFeishu Channel = "feishu" + ChannelDingTalk Channel = "dingtalk" + ChannelWeCom Channel = "wecom" + ChannelWebhook Channel = "webhook" +) + +type MessageType string + +const ( + MessageText MessageType = "text" + MessageColorText MessageType = "color_text" + MessageMarkdown MessageType = "markdown" + MessageCard MessageType = "card" +) + +type Color string + +const ( + ColorDefault Color = "default" + ColorInfo Color = "info" + ColorSuccess Color = "success" + ColorWarning Color = "warning" + ColorDanger Color = "danger" + ColorMuted Color = "muted" +) + +type CardTheme string + +const ( + CardThemeDefault CardTheme = "default" + CardThemeBlue CardTheme = "blue" + CardThemeWathet CardTheme = "wathet" + CardThemeTurquoise CardTheme = "turquoise" + CardThemeGreen CardTheme = "green" + CardThemeYellow CardTheme = "yellow" + CardThemeOrange CardTheme = "orange" + CardThemeRed CardTheme = "red" + CardThemeCarmine CardTheme = "carmine" + CardThemeViolet CardTheme = "violet" + CardThemePurple CardTheme = "purple" + CardThemeIndigo CardTheme = "indigo" + CardThemeGrey CardTheme = "grey" +) + +type Message struct { + Type MessageType `json:"type" dc:"消息类型"` + Title string `json:"title,omitempty" dc:"消息标题"` + Content string `json:"content,omitempty" dc:"文本或 Markdown 消息正文"` + Segments []TextSegment `json:"segments,omitempty" dc:"带颜色文本片段列表"` + Card *CardContent `json:"card,omitempty" dc:"卡片消息内容"` +} + +type TextSegment struct { + Text string `json:"text" dc:"文本内容"` + Color Color `json:"color,omitempty" dc:"文本语义颜色"` + Bold bool `json:"bold,omitempty" dc:"是否加粗显示"` +} + +type CardContent struct { + Title string `json:"title,omitempty" dc:"卡片标题"` + Theme CardTheme `json:"theme,omitempty" dc:"卡片主题,飞书映射为 Header template"` + Markdown string `json:"markdown,omitempty" dc:"卡片 Markdown 正文"` + Fields []CardField `json:"fields,omitempty" dc:"卡片字段列表"` + Actions []CardAction `json:"actions,omitempty" dc:"卡片跳转操作列表"` +} + +type CardField struct { + Name string `json:"name" dc:"字段名称"` + Value string `json:"value" dc:"字段值"` +} + +type CardAction struct { + Text string `json:"text" dc:"操作按钮文案"` + URL string `json:"url" dc:"操作跳转地址"` +} + +type Config struct { + Webhook string `json:"webhook" dc:"机器人 Webhook 地址"` + Security *SecurityConfig `json:"security,omitempty" dc:"当前机器人独立安全校验配置"` + Timeout time.Duration `json:"timeout,omitempty" dc:"HTTP 请求超时时间"` +} + +type SecurityConfig struct { + SignEnabled bool `json:"sign_enabled" dc:"是否启用机器人签名校验"` + SignSecret string `json:"sign_secret,omitempty" dc:"机器人签名校验密钥"` +} + +type ZapConfig struct { + Channels []Channel `json:"channels" dc:"接收日志通知的消息渠道列表"` + Levels []zapcore.Level `json:"levels,omitempty" dc:"触发消息发送的 Zap 日志等级列表,空值默认 warn 和 error"` + OnError func(error) `json:"-" dc:"异步消息发送失败时的处理函数"` +} + +type Capabilities struct { + Text bool `json:"text" dc:"是否支持普通文本"` + ColorText bool `json:"color_text" dc:"是否支持带颜色文本"` + Markdown bool `json:"markdown" dc:"是否支持 Markdown"` + Card bool `json:"card" dc:"是否支持卡片消息"` +} + +type Sender interface { + Send(ctx context.Context, message Message) error + SendAsync(ctx context.Context, message Message) <-chan error +} diff --git a/zap.go b/zap.go new file mode 100644 index 0000000..ba053a8 --- /dev/null +++ b/zap.go @@ -0,0 +1,172 @@ +package notice + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "sort" + "sync" + + "go.uber.org/zap" + "go.uber.org/zap/zapcore" +) + +type zapNoticeCore struct { + senders []Sender `json:"-" dc:"日志通知使用的消息发送实例列表"` + levels map[zapcore.Level]struct{} `json:"-" dc:"触发消息通知的精确日志等级集合"` + fields []zapcore.Field `json:"-" dc:"通过 Logger.With 附加的上下文字段"` + pending *sync.WaitGroup `json:"-" dc:"等待尚未完成的异步消息发送"` + onError func(error) `json:"-" dc:"异步消息发送失败处理函数"` +} + +func (f *Factory) WrapZap(base *zap.Logger, config ZapConfig) (*zap.Logger, error) { + if base == nil { + return nil, fmt.Errorf("%w: base logger is nil", ErrInvalidZapConfig) + } + channels, levels, err := normalizeZapConfig(config) + if err != nil { + return nil, err + } + senders := make([]Sender, 0, len(channels)) + for _, channel := range channels { + sender, getErr := f.Get(channel) + if getErr != nil { + return nil, getErr + } + senders = append(senders, sender) + } + core := &zapNoticeCore{ + senders: senders, + levels: levels, + fields: []zapcore.Field{}, + pending: &sync.WaitGroup{}, + onError: config.OnError, + } + return base.WithOptions(zap.WrapCore(func(original zapcore.Core) zapcore.Core { + return zapcore.NewTee(original, core) + })), nil +} + +func normalizeZapConfig(config ZapConfig) ([]Channel, map[zapcore.Level]struct{}, error) { + if len(config.Channels) == 0 { + return nil, nil, fmt.Errorf("%w: channels are empty", ErrInvalidZapConfig) + } + seen := make(map[Channel]struct{}, len(config.Channels)) + channels := make([]Channel, 0, len(config.Channels)) + for _, channel := range config.Channels { + if !validChannel(channel) { + return nil, nil, fmt.Errorf("%w: unsupported channel %q", ErrInvalidZapConfig, channel) + } + if _, exists := seen[channel]; exists { + continue + } + seen[channel] = struct{}{} + channels = append(channels, channel) + } + configuredLevels := config.Levels + if len(configuredLevels) == 0 { + configuredLevels = []zapcore.Level{zapcore.WarnLevel, zapcore.ErrorLevel} + } + levels := make(map[zapcore.Level]struct{}, len(configuredLevels)) + for _, level := range configuredLevels { + if level < zapcore.DebugLevel || level > zapcore.FatalLevel { + return nil, nil, fmt.Errorf("%w: invalid level %d", ErrInvalidZapConfig, level) + } + levels[level] = struct{}{} + } + return channels, levels, nil +} + +func (c *zapNoticeCore) Enabled(level zapcore.Level) bool { + _, exists := c.levels[level] + return exists +} + +func (c *zapNoticeCore) With(fields []zapcore.Field) zapcore.Core { + cloned := *c + cloned.fields = make([]zapcore.Field, 0, len(c.fields)+len(fields)) + cloned.fields = append(cloned.fields, c.fields...) + cloned.fields = append(cloned.fields, fields...) + return &cloned +} + +func (c *zapNoticeCore) Check(entry zapcore.Entry, checked *zapcore.CheckedEntry) *zapcore.CheckedEntry { + if c.Enabled(entry.Level) { + return checked.AddCore(entry, c) + } + return checked +} + +func (c *zapNoticeCore) Write(entry zapcore.Entry, fields []zapcore.Field) error { + message := zapEntryMessage(entry, append(append([]zapcore.Field{}, c.fields...), fields...)) + c.pending.Add(1) + go func() { + defer c.pending.Done() + var sendErrors []error + for index, sender := range c.senders { + if err := sender.Send(context.Background(), message); err != nil { + sendErrors = append(sendErrors, fmt.Errorf("notice sender %d: %w", index, err)) + } + } + if len(sendErrors) > 0 && c.onError != nil { + c.onError(errors.Join(sendErrors...)) + } + }() + return nil +} + +func (c *zapNoticeCore) Sync() error { + c.pending.Wait() + return nil +} + +func zapEntryMessage(entry zapcore.Entry, fields []zapcore.Field) Message { + titleSuffix := entry.LoggerName + if titleSuffix == "" { + titleSuffix = "日志告警" + } + cardFields := encodeZapFields(fields) + cardFields = append(cardFields, CardField{Name: "timestamp", Value: entry.Time.Format("2006-01-02T15:04:05.000Z07:00")}) + if entry.Caller.Defined { + cardFields = append(cardFields, CardField{Name: "caller", Value: entry.Caller.TrimmedPath()}) + } + return Card(CardContent{ + Title: fmt.Sprintf("[%s] %s", entry.Level.CapitalString(), titleSuffix), + Theme: zapLevelTheme(entry.Level), + Markdown: entry.Message, + Fields: cardFields, + }) +} + +func encodeZapFields(fields []zapcore.Field) []CardField { + encoder := zapcore.NewMapObjectEncoder() + for _, field := range fields { + field.AddTo(encoder) + } + keys := make([]string, 0, len(encoder.Fields)) + for key := range encoder.Fields { + keys = append(keys, key) + } + sort.Strings(keys) + result := make([]CardField, 0, len(keys)) + for _, key := range keys { + data, err := json.Marshal(encoder.Fields[key]) + if err != nil { + data = []byte(fmt.Sprint(encoder.Fields[key])) + } + result = append(result, CardField{Name: key, Value: string(data)}) + } + return result +} + +func zapLevelTheme(level zapcore.Level) CardTheme { + switch { + case level >= zapcore.ErrorLevel: + return CardThemeRed + case level == zapcore.WarnLevel: + return CardThemeOrange + default: + return CardThemeBlue + } +} diff --git a/多渠道消息通知平台设计文档.md b/多渠道消息通知平台设计文档.md new file mode 100644 index 0000000..f2e63cc --- /dev/null +++ b/多渠道消息通知平台设计文档.md @@ -0,0 +1,770 @@ +# 多渠道消息通知 Package 设计 + +## 1. 目标 + +将消息通知能力实现为可被 Go 项目直接引入的第三方 package,不提供 HTTP 服务。 + +支持渠道: + +- 飞书自定义机器人 +- 钉钉自定义机器人 +- 企业微信群机器人 +- 通用 Webhook + +支持统一消息格式: + +- 普通文本 +- 带颜色文本 +- Markdown +- 卡片消息 +- 包装现有 Zap Logger,并按日志等级触发消息通知 + +支持同步和异步发送。所有渠道的 HTTP/HTTPS 请求统一使用: + +```go +git.zhangdeman.cn/zhangdeman/network +``` + +## 2. 官方能力与统一策略 + +### 2.1 能力矩阵 + +| 统一格式 | 飞书 | 钉钉 | 企业微信 | +|---|---|---|---| +| 普通文本 | `text` | `text` | `text` | +| 带颜色文本 | 卡片 `plain_text.text_color` 或 `lark_md` 彩色文本 | 官方 Webhook Markdown 未提供稳定的行内颜色契约,自动降级 | `markdown` 的 `info`、`comment`、`warning` 三种内置颜色 | +| Markdown | 卡片 Markdown / `lark_md` | `markdown` | `markdown` | +| 卡片 | `interactive` Card JSON 2.0 | `actionCard`;多图文可使用 `feedCard` | `template_card` | +| 卡片主题 | Header `template` 原生支持 | 无等价主题,忽略 | 无等价主题,忽略 | + +统一 package 只保证内容和语义一致,不保证三个客户端的颜色、间距、字体及卡片布局完全一致。 + +### 2.2 颜色使用语义 + +调用方使用语义颜色,不直接传平台颜色名称或十六进制值: + +```go +type Color string + +const ( + ColorDefault Color = "default" + ColorInfo Color = "info" + ColorSuccess Color = "success" + ColorWarning Color = "warning" + ColorDanger Color = "danger" + ColorMuted Color = "muted" +) +``` + +Renderer 按渠道映射: + +| 语义颜色 | 飞书 | 企业微信 | 钉钉降级 | +|---|---|---|---| +| `default` | `default` | 普通文本 | 普通文本 | +| `info` | `blue` | `info`(绿色) | `【提示】` 前缀 | +| `success` | `green` | `info`(绿色) | `【成功】` 前缀 | +| `warning` | `orange` | `warning`(橙红色) | `**【警告】**` | +| `danger` | `red` | `warning`(橙红色) | `**【异常】**` | +| `muted` | `grey` | `comment`(灰色) | 普通文本 | + +钉钉降级时必须保留全部文字内容,只丢失颜色表现。 + +### 2.3 飞书卡片主题 + +卡片主题独立于正文语义颜色。统一模型提供 `CardTheme`,飞书 Renderer 将其直接写入 Card Header 的 `template`: + +```go +type CardTheme string + +const ( + CardThemeDefault CardTheme = "default" + CardThemeBlue CardTheme = "blue" + CardThemeWathet CardTheme = "wathet" + CardThemeTurquoise CardTheme = "turquoise" + CardThemeGreen CardTheme = "green" + CardThemeYellow CardTheme = "yellow" + CardThemeOrange CardTheme = "orange" + CardThemeRed CardTheme = "red" + CardThemeCarmine CardTheme = "carmine" + CardThemeViolet CardTheme = "violet" + CardThemePurple CardTheme = "purple" + CardThemeIndigo CardTheme = "indigo" + CardThemeGrey CardTheme = "grey" +) +``` + +`CardTheme` 对飞书完整生效。钉钉 ActionCard 和企业微信 Template Card 没有等价的整卡主题字段,因此对应 Renderer 忽略该字段,但不会影响卡片正文和按钮。 + +## 3. 使用方式 + +### 3.1 创建 Factory + +```go +factory := notice.NewFactory(map[notice.Channel]notice.Config{ + notice.ChannelFeishu: { + Webhook: os.Getenv("FEISHU_WEBHOOK"), + Security: ¬ice.SecurityConfig{ + SignEnabled: true, + SignSecret: os.Getenv("FEISHU_SIGN_SECRET"), + }, + Timeout: 5 * time.Second, + }, + notice.ChannelDingTalk: { + Webhook: os.Getenv("DINGTALK_WEBHOOK"), + Security: ¬ice.SecurityConfig{ + SignEnabled: true, + SignSecret: os.Getenv("DINGTALK_SIGN_SECRET"), + }, + Timeout: 5 * time.Second, + }, + notice.ChannelWeCom: { + Webhook: os.Getenv("WECOM_WEBHOOK"), + Timeout: 5 * time.Second, + }, +}) +``` + +### 3.2 按 Channel 获取 Sender + +```go +sender, err := factory.Get(notice.ChannelFeishu) +if err != nil { + return err +} +``` + +`Factory.Get(channel)` 的行为: + +- 实例已存在:直接返回缓存实例。 +- 实例不存在:读取该渠道配置,初始化、缓存并返回。 +- 渠道未配置:返回 `ErrChannelNotConfigured`。 +- 并发获取同一 Channel:只初始化一个实例。 + +### 3.3 同步发送 + +```go +err := sender.Send(ctx, notice.Markdown( + "订单服务告警", + "**错误率超过阈值**\n\n当前值:3.2%", +)) +``` + +### 3.4 异步发送 + +```go +result := sender.SendAsync(ctx, notice.Text("数据同步任务已完成")) + +go func() { + if err := <-result; err != nil { + log.Printf("send notice failed: %v", err) + } +}() +``` + +`SendAsync` 使用当前进程内的 goroutine,返回容量为 1 的只读错误通道。异步任务不持久化,进程退出时未完成的消息可能丢失。 + +### 3.5 包装 Zap Logger + +```go +baseLogger, err := zap.NewProduction() +if err != nil { + return err +} + +logger, err := factory.WrapZap(baseLogger, notice.ZapConfig{ + Channels: []notice.Channel{ + notice.ChannelFeishu, + notice.ChannelDingTalk, + }, + Levels: []zapcore.Level{ + zapcore.WarnLevel, + zapcore.ErrorLevel, + }, +}) +if err != nil { + return err +} +defer logger.Sync() + +logger.Info("服务启动完成") // 不发送消息 +logger.Warn("订单延迟升高", zap.Int("delay_ms", 2500)) // 发送到飞书和钉钉 +logger.Error("订单创建失败", zap.String("order_id", "O1001")) // 发送到飞书和钉钉 +``` + +`Levels` 未设置或为空时,默认仅由 `zapcore.WarnLevel` 和 `zapcore.ErrorLevel` 触发消息发送。等级采用精确匹配,不是最低等级阈值;如需由 DPanic、Panic 或 Fatal 触发,必须显式加入对应等级。 + +## 4. 对外数据模型 + +所有结构体字段统一包含以下 tag: + +- `json`:JSON 字段名称;可选字段增加 `omitempty`。 +- `dc`:字段中文含义,用于文档生成、配置提示或反射读取。 +- 运行时字段使用 `json:"-"`,明确禁止序列化。 + +### 4.1 基础类型 + +```go +type Channel string + +const ( + ChannelFeishu Channel = "feishu" + ChannelDingTalk Channel = "dingtalk" + ChannelWeCom Channel = "wecom" + ChannelWebhook Channel = "webhook" +) + +type MessageType string + +const ( + MessageText MessageType = "text" + MessageColorText MessageType = "color_text" + MessageMarkdown MessageType = "markdown" + MessageCard MessageType = "card" +) +``` + +### 4.2 消息结构 + +```go +type Message struct { + Type MessageType `json:"type" dc:"消息类型"` + Title string `json:"title,omitempty" dc:"消息标题"` + Content string `json:"content,omitempty" dc:"文本或 Markdown 消息正文"` + Segments []TextSegment `json:"segments,omitempty" dc:"带颜色文本片段列表"` + Card *CardContent `json:"card,omitempty" dc:"卡片消息内容"` +} + +type TextSegment struct { + Text string `json:"text" dc:"文本内容"` + Color Color `json:"color,omitempty" dc:"文本语义颜色"` + Bold bool `json:"bold,omitempty" dc:"是否加粗显示"` +} + +type CardContent struct { + Title string `json:"title,omitempty" dc:"卡片标题"` + Theme CardTheme `json:"theme,omitempty" dc:"卡片主题,飞书映射为 Header template"` + Markdown string `json:"markdown,omitempty" dc:"卡片 Markdown 正文"` + Fields []CardField `json:"fields,omitempty" dc:"卡片字段列表"` + Actions []CardAction `json:"actions,omitempty" dc:"卡片跳转操作列表"` +} + +type CardField struct { + Name string `json:"name" dc:"字段名称"` + Value string `json:"value" dc:"字段值"` +} + +type CardAction struct { + Text string `json:"text" dc:"操作按钮文案"` + URL string `json:"url" dc:"操作跳转地址"` +} +``` + +卡片 Action 仅支持打开 URL,不提供按钮回调。package 本身没有 HTTP 服务,无法接收平台交互事件。 + +### 4.3 消息构造方法 + +使用构造方法保证同一时间只有对应类型的字段生效: + +```go +func Text(content string) Message + +func ColorText(segments ...TextSegment) Message + +func Markdown(title, content string) Message + +func Card(content CardContent) Message +``` + +调用示例: + +```go +message := notice.ColorText( + notice.TextSegment{Text: "状态:"}, + notice.TextSegment{ + Text: "失败", + Color: notice.ColorDanger, + Bold: true, + }, +) +``` + +```go +message := notice.Card(notice.CardContent{ + Title: "发布结果", + Theme: notice.CardThemeGreen, + Markdown: "服务已成功发布到生产环境。", + Fields: []notice.CardField{ + {Name: "服务", Value: "order-service"}, + {Name: "版本", Value: "v1.8.0"}, + }, + Actions: []notice.CardAction{ + {Text: "查看发布详情", URL: "https://example.com/releases/1001"}, + }, +}) +``` + +## 5. Sender 与 Factory + +### 5.1 对外接口 + +```go +type Config struct { + Webhook string `json:"webhook" dc:"机器人 Webhook 地址"` + Security *SecurityConfig `json:"security,omitempty" dc:"当前机器人独立安全校验配置"` + Timeout time.Duration `json:"timeout,omitempty" dc:"HTTP 请求超时时间"` +} + +type SecurityConfig struct { + SignEnabled bool `json:"sign_enabled" dc:"是否启用机器人签名校验"` + SignSecret string `json:"sign_secret,omitempty" dc:"机器人签名校验密钥"` +} + +type ZapConfig struct { + Channels []Channel `json:"channels" dc:"接收日志通知的消息渠道列表"` + Levels []zapcore.Level `json:"levels,omitempty" dc:"触发消息发送的 Zap 日志等级列表,空值默认 warn 和 error"` + OnError func(error) `json:"-" dc:"异步消息发送失败时的处理函数"` +} + +type Sender interface { + Send(ctx context.Context, message Message) error + SendAsync(ctx context.Context, message Message) <-chan error +} + +func New(channel Channel, config Config) (Sender, error) +func NewFactory(configs map[Channel]Config) *Factory +func (f *Factory) Get(channel Channel) (Sender, error) +func (f *Factory) WrapZap(base *zap.Logger, config ZapConfig) (*zap.Logger, error) +``` + +`Config` 的 JSON tag 用于配置加载,不代表可以将配置直接写入日志或接口响应;Webhook 和签名密钥必须保持脱敏。`SecurityConfig` 隶属于单个机器人配置,不同渠道实例可独立决定是否启用签名并使用不同密钥。 + +`ZapConfig.OnError` 是运行时函数,因此使用 `json:"-"`。未配置时,异步发送错误不再写回被包装的 Logger,避免日志通知递归触发。 + +### 5.2 Factory 缓存 + +```go +type Factory struct { + mu sync.RWMutex `json:"-" dc:"实例缓存读写锁"` + configs map[Channel]Config `json:"-" dc:"按渠道保存的初始化配置"` + instances map[Channel]Sender `json:"-" dc:"按渠道缓存的 Sender 实例"` +} + +func (f *Factory) Get(channel Channel) (Sender, error) { + f.mu.RLock() + instance, ok := f.instances[channel] + f.mu.RUnlock() + if ok { + return instance, nil + } + + f.mu.Lock() + defer f.mu.Unlock() + + if instance, ok = f.instances[channel]; ok { + return instance, nil + } + + config, ok := f.configs[channel] + if !ok { + return nil, ErrChannelNotConfigured + } + + instance, err := New(channel, config) + if err != nil { + return nil, err + } + + f.instances[channel] = instance + return instance, nil +} +``` + +只有初始化成功的实例才写入缓存。 + +## 6. Renderer 设计 + +渠道差异由 Renderer 处理,Sender 不直接拼装平台 JSON。 + +```go +type Capabilities struct { + Text bool `json:"text" dc:"是否支持普通文本"` + ColorText bool `json:"color_text" dc:"是否支持带颜色文本"` + Markdown bool `json:"markdown" dc:"是否支持 Markdown"` + Card bool `json:"card" dc:"是否支持卡片消息"` +} + +type renderedRequest struct { + URL string `json:"url" dc:"最终请求地址"` + Body []byte `json:"body" dc:"平台请求体"` +} + +type networkRequest struct { + Context context.Context `json:"-" dc:"请求上下文"` + URL string `json:"url" dc:"请求地址"` + Body []byte `json:"body" dc:"JSON 请求体"` + Timeout time.Duration `json:"timeout" dc:"请求超时时间"` +} + +type networkResponse struct { + StatusCode int `json:"status_code" dc:"HTTP 响应状态码"` + Body []byte `json:"body" dc:"HTTP 响应正文"` +} + +type renderer interface { + Capabilities() Capabilities + Render(config Config, message Message) (renderedRequest, error) + ValidateResponse(response networkResponse) error +} + +type channelSender struct { + channel Channel `json:"-" dc:"当前消息渠道"` + renderer renderer `json:"-" dc:"渠道消息渲染器"` + client networkClient `json:"-" dc:"HTTP 网络请求客户端"` + config Config `json:"-" dc:"当前渠道配置"` +} +``` + +发送流程: + +```text +Sender.Send + → 校验 Message + → Renderer 转换平台请求体 + → 生成渠道签名 + → 使用 network package 发送 + → 检查 HTTP 状态码和平台业务错误码 +``` + +禁止调用方传入钉钉、企微或飞书原生 JSON。这样可以避免业务代码绑定平台协议,也能统一处理转义、颜色映射和字段限制。 + +## 7. 各渠道 Renderer + +### 7.1 飞书 + +- 普通文本:发送 `msg_type=text`。 +- 带颜色文本:渲染为 Card JSON 2.0 的 `plain_text.text_color` 或 `lark_md` 彩色文本。 +- Markdown:渲染为 `interactive` 卡片中的 Markdown 组件。 +- 卡片:渲染为 `interactive` Card JSON 2.0。 +- `CardContent.Theme` 原样映射为卡片 Header 的 `template`;未设置时使用 `default`。 +- `Fields` 渲染为 Markdown 字段列表,`Actions` 渲染为直接位于 `body.elements` 的 Card JSON 2.0 Button。 +- 当前机器人启用 `Security.SignEnabled` 时,使用其独立 `SignSecret` 生成飞书要求的秒级时间戳和签名。 + +飞书 Card JSON 2.0 依赖较新的客户端版本;低版本客户端可能显示升级提示。若需要兼容旧客户端,可将 Renderer 切换为 Card JSON 1.0 实现,但统一接口不变。 + +### 7.2 钉钉 + +- 普通文本:发送 `msgtype=text`。 +- 带颜色文本:转换为 Markdown;保留粗体,并将颜色转换为语义前缀。 +- Markdown:发送 `msgtype=markdown`。 +- 卡片:有按钮时发送 `actionCard`;只有多条图文链接时可扩展为 `feedCard`。 +- `Fields` 拼接到 ActionCard 的 Markdown 正文。 +- `Actions` 映射为 ActionCard 的独立跳转按钮。 +- 当前机器人启用 `Security.SignEnabled` 时,使用其独立 `SignSecret` 生成钉钉要求的毫秒级时间戳和签名。 + +不使用未经官方承诺的 HTML 字体颜色写法,避免不同钉钉客户端显示不一致。 + +### 7.3 企业微信 + +- 普通文本:发送 `msgtype=text`。 +- 带颜色文本:发送 `msgtype=markdown`,映射为 ``。 +- Markdown:发送 `msgtype=markdown`。 +- 卡片:发送 `msgtype=template_card`,默认使用 `text_notice`。 +- `Fields` 映射为 `horizontal_content_list`。 +- `Actions` 优先映射为 `jump_list`;主操作可映射为 `card_action`。 +- HTTP 成功后继续检查企业微信响应中的业务错误码。 + +企业微信颜色只有三种内置值,因此 `danger` 和 `warning` 都映射为 `warning`,`info` 和 `success` 都映射为 `info`。 + +### 7.4 通用 Webhook + +通用 Webhook 直接发送统一结构: + +```json +{ + "type": "card", + "card": { + "title": "发布结果", + "theme": "success", + "markdown": "服务已成功发布到生产环境。", + "fields": [ + {"name": "服务", "value": "order-service"} + ], + "actions": [ + {"text": "查看详情", "url": "https://example.com/releases/1001"} + ] + } +} +``` + +HTTP 状态码为 `200` 至 `299` 时视为成功。 + +## 8. 参数校验 + +### 8.1 Config + +- Channel 必须受支持。 +- Factory 获取的 Channel 必须存在配置。 +- Webhook 不能为空且必须使用 HTTPS。 +- Timeout 未设置时默认为 5 秒。 +- 飞书和钉钉支持独立签名配置;启用签名时 `SignSecret` 不能为空。 +- `Security` 未设置或 `SignEnabled=false` 时不生成签名。 +- 企业微信和通用 Webhook 不接受该签名配置。 + +### 8.2 Message + +- `Message.Type` 必须为已支持类型。 +- 普通文本和 Markdown 的 `Content` 不能为空。 +- 带颜色文本至少包含一个非空 Segment。 +- 卡片的 `Title` 或 `Markdown` 至少一个非空。 +- Segment 的 Color 必须为预定义语义颜色。 +- Card Theme 必须为预定义的 `CardTheme`;空值按 `CardThemeDefault` 处理。 +- Card Action 的 Text 和 HTTPS URL 不能为空。 + +校验失败不发起网络请求。 + +### 8.3 ZapConfig + +- `Channels` 至少包含一个渠道。 +- Channel 必须受支持并已在 Factory 中配置。 +- 重复 Channel 自动去重,保持首次出现的顺序。 +- `Levels` 为空时设置为 `WarnLevel`、`ErrorLevel`。 +- `Levels` 非空时必须是合法的 Zap Level,并按精确等级匹配。 +- `OnError` 不能使用被包装后的 Logger 记录错误,否则可能形成通知递归。 + +## 9. 同步与异步 + +```go +func (s *channelSender) Send(ctx context.Context, message Message) error { + if err := validateMessage(message); err != nil { + return err + } + request, err := s.renderer.Render(s.config, message) + if err != nil { + return err + } + response, err := s.client.Post(networkRequest{ + Context: ctx, + URL: request.URL, + Body: request.Body, + Timeout: s.config.Timeout, + }) + if err != nil { + return err + } + return s.renderer.ValidateResponse(response) +} + +func (s *channelSender) SendAsync(ctx context.Context, message Message) <-chan error { + result := make(chan error, 1) + + go func() { + defer close(result) + result <- s.Send(ctx, message) + }() + + return result +} +``` + +package 内不持久化异步任务、不自动重试。调用方必须保证 Context 在异步发送完成前有效。 + +## 10. Zap Logger 包装 + +### 10.1 包装方式 + +package 实现一个附加的 `zapcore.Core`,通过 `zap.WrapCore` 和 `zapcore.NewTee` 与原 Logger Core 组合。原有控制台、文件或其他日志输出保持不变;只有匹配的日志额外发送消息通知。 + +```go +func (f *Factory) WrapZap( + base *zap.Logger, + config ZapConfig, +) (*zap.Logger, error) { + if base == nil { + return nil, ErrInvalidZapConfig + } + + config = normalizeZapConfig(config) + senders, err := f.getSenders(config.Channels) + if err != nil { + return nil, err + } + + noticeCore := newZapNoticeCore(senders, config) + logger := base.WithOptions(zap.WrapCore(func(core zapcore.Core) zapcore.Core { + return zapcore.NewTee(core, noticeCore) + })) + + return logger, nil +} +``` + +### 10.2 Core 数据结构 + +```go +type zapNoticeCore struct { + senders []Sender `json:"-" dc:"日志通知使用的消息发送实例列表"` + levels map[zapcore.Level]struct{} `json:"-" dc:"触发消息通知的精确日志等级集合"` + fields []zapcore.Field `json:"-" dc:"通过 Logger.With 附加的上下文字段"` + pending *sync.WaitGroup `json:"-" dc:"等待尚未完成的异步消息发送"` + onError func(error) `json:"-" dc:"异步消息发送失败处理函数"` +} +``` + +Core 实现 `Enabled`、`With`、`Check`、`Write` 和 `Sync`: + +- `Enabled`:判断日志等级是否在 `levels` 集合中。 +- `With`:复制 Core 并保存 Zap 上下文字段。 +- `Check`:等级匹配时将当前 Core 加入 `CheckedEntry`。 +- `Write`:将 Entry 和 Fields 转为卡片消息,并在 goroutine 中依次发送到渠道列表。 +- `Sync`:等待已触发的异步消息发送完成。 + +### 10.3 等级匹配 + +```go +func normalizeZapConfig(config ZapConfig) ZapConfig { + if len(config.Levels) == 0 { + config.Levels = []zapcore.Level{ + zapcore.WarnLevel, + zapcore.ErrorLevel, + } + } + return config +} + +func (c *zapNoticeCore) Enabled(level zapcore.Level) bool { + _, ok := c.levels[level] + return ok +} +``` + +默认配置只精确匹配 Warn 和 Error: + +| Zap 方法 | 默认发送消息 | +|---|---:| +| `Debug` | 否 | +| `Info` | 否 | +| `Warn` | 是 | +| `Error` | 是 | +| `DPanic` | 否,需显式配置 | +| `Panic` | 否,需显式配置 | +| `Fatal` | 否,需显式配置 | + +### 10.4 日志消息转换 + +日志统一转换为 `CardContent`: + +- 标题:`[LEVEL] LoggerName`;LoggerName 为空时使用 `[LEVEL] 日志告警`。 +- 正文:Zap Entry 的 Message。 +- 字段:Logger.With 字段和当前调用字段,转换为 Card Fields。 +- 时间:增加 `timestamp` 字段。 +- 调用位置:Entry 中存在 Caller 时增加 `caller` 字段。 +- 主题:Debug/Info 使用蓝色,Warn 使用橙色,Error 及更严重等级使用红色。 + +日志字段由 Zap Encoder 转为 JSON 后再生成卡片字段,避免自行判断 Zap Field 的内部类型。Webhook、Token、签名密钥等敏感字段应由业务方在写日志前脱敏。 + +### 10.5 异步发送约束 + +Zap 的 `Core.Write` 没有 `context.Context` 参数,因此日志通知使用 `context.Background()`,实际超时由各 Sender 的 `Config.Timeout` 控制。 + +`Write` 不等待第三方平台响应,避免网络请求阻塞正常日志写入。应用退出前调用 `logger.Sync()`,通知 Core 的 `Sync` 会等待已触发的发送任务完成。发送失败时调用 `ZapConfig.OnError`;未配置 `OnError` 时忽略回调,但不得把错误重新写入被包装 Logger。 + +## 11. Network 包约束 + +所有外部请求必须通过 `git.zhangdeman.cn/zhangdeman/network` 发起,不直接创建 `net/http.Client`,也不引入各平台 SDK。 + +```go +type networkClient interface { + Post(request networkRequest) (networkResponse, error) +} +``` + +- 每次发送只执行一次 HTTP 请求。 +- 使用 Config.Timeout 控制超时。 +- 不关闭 TLS 证书校验。 +- 错误和日志不输出完整 Webhook、Token 或签名密钥。 +- 具体构造函数以锁定版本的 network package API 为准。 +- 单元测试通过 Fake Network Client 验证请求体。 + +## 12. 错误定义 + +```go +var ( + ErrUnsupportedChannel = errors.New("notice: unsupported channel") + ErrChannelNotConfigured = errors.New("notice: channel not configured") + ErrInvalidConfig = errors.New("notice: invalid config") + ErrInvalidMessage = errors.New("notice: invalid message") + ErrInvalidZapConfig = errors.New("notice: invalid zap config") +) +``` + +参数错误使用 `%w` 包装,调用方通过 `errors.Is` 判断类型。HTTP 非 2xx 返回带渠道、状态码与响应正文的 `HTTPError`;平台业务码失败返回带渠道、业务码与错误信息的 `PlatformError`。 + +## 13. 建议代码结构 + +```text +notice/ + README.md + errors.go + factory.go + go.mod + go.sum + message.go + notice_test.go + renderer.go + renderer_dingtalk.go + renderer_feishu.go + renderer_webhook.go + renderer_wecom.go + sender.go + transport.go + types.go + zap.go +``` + +## 14. 测试重点 + +- Factory 并发获取同一 Channel 只初始化一次。 +- 四种消息构造方法生成正确的 Message。 +- 所有结构体字段均包含 `json` 和 `dc` tag,运行时字段均为 `json:"-"`。 +- 各 Renderer 对四种消息类型生成正确的官方请求结构。 +- 企微六种语义颜色正确收敛到三种官方颜色。 +- 钉钉带颜色文本降级后不丢失文字。 +- 飞书 Card JSON 2.0 的主题、字段和按钮映射正确。 +- 所有 `CardTheme` 枚举都能正确写入飞书 Header `template`。 +- JSON、Markdown、URL 和特殊字符正确转义。 +- HTTP 2xx 但平台业务码失败时返回 `PlatformError`。 +- 日志和错误中不包含 Webhook、Token 或签名密钥。 +- Fake Network Client 可以覆盖同步和异步发送。 +- ZapConfig 未设置 Levels 时只触发 Warn 和 Error。 +- 自定义 Levels 采用精确匹配,不错误扩展为等级阈值。 +- ZapConfig 的重复 Channels 被去重,未配置 Channel 返回错误。 +- 被包装 Logger 保留原 Core 输出,同时增加消息通知输出。 +- Zap 的 Logger.With 字段和当前日志字段都能进入卡片消息。 +- Zap 通知异步发送,不阻塞 Core.Write;Sync 等待在途任务完成。 +- OnError 不会通过被包装 Logger 形成递归通知。 + +## 15. 验收标准 + +- 业务项目通过引入 package 即可发送消息,无需部署 HTTP 服务。 +- 支持普通文本、带颜色文本、Markdown 和卡片消息。 +- 支持飞书、钉钉、企业微信和通用 Webhook。 +- 通过 `Factory.Get(channel)` 获取并复用 Sender 实例。 +- 支持 `Send` 和 `SendAsync`。 +- 渠道差异只存在于 Renderer,调用方不拼装平台 JSON。 +- 颜色不受支持时按语义降级且不丢失内容。 +- 所有网络请求使用指定 network package。 +- 可以将现有 `*zap.Logger` 包装为带消息通知能力的新 Logger。 +- 可以指定一个或多个消息渠道,日志通知发送到全部指定渠道。 +- 可以指定触发日志等级,未指定时默认 Warn 和 Error。 +- Zap 原有日志输出不受影响,消息通知默认异步执行。 + +## 16. 官方参考 + +- [飞书:自定义机器人使用指南](https://open.feishu.cn/document/ukTMukTMukTM/ucTM5YjL3ETO24yNxkjN?lang=zh-CN) +- [飞书:使用自定义机器人发送卡片](https://open.feishu.cn/document/uAjLw4CM/ukzMukzMukzM/feishu-cards/quick-start/send-message-cards-with-custom-bot) +- [飞书:Card JSON 2.0 普通文本与彩色文本](https://open.feishu.cn/document/feishu-cards/card-json-v2-components/content-components/plain-text) +- [飞书:卡片标题与主题样式](https://open.feishu.cn/document/common-capabilities/message-card/message-cards-content/card-header) +- [钉钉:自定义机器人接入](https://open.dingtalk.com/document/orgapp/custom-robot-access) +- [企业微信:群机器人配置说明](https://developer.work.weixin.qq.com/document/path/91770) +- [Zap:Logger 与 WrapCore](https://pkg.go.dev/go.uber.org/zap) +- [Zap:zapcore.Core](https://pkg.go.dev/go.uber.org/zap/zapcore#Core)