26 KiB
多渠道消息通知 Package 设计
1. 目标
将消息通知能力实现为可被 Go 项目直接引入的第三方 package,不提供 HTTP 服务。
支持渠道:
- 飞书自定义机器人
- 钉钉自定义机器人
- 企业微信群机器人
- 通用 Webhook
支持统一消息格式:
- 普通文本
- 带颜色文本
- Markdown
- 卡片消息
- 包装现有 Zap Logger,并按日志等级触发消息通知
支持同步和异步发送。所有渠道的 HTTP/HTTPS 请求统一使用:
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 颜色使用语义
调用方使用语义颜色,不直接传平台颜色名称或十六进制值:
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:
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
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
sender, err := factory.Get(notice.ChannelFeishu)
if err != nil {
return err
}
Factory.Get(channel) 的行为:
- 实例已存在:直接返回缓存实例。
- 实例不存在:读取该渠道配置,初始化、缓存并返回。
- 渠道未配置:返回
ErrChannelNotConfigured。 - 并发获取同一 Channel:只初始化一个实例。
3.3 同步发送
err := sender.Send(ctx, notice.Markdown(
"订单服务告警",
"**错误率超过阈值**\n\n当前值:3.2%",
))
3.4 异步发送
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
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 基础类型
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 消息结构
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 消息构造方法
使用构造方法保证同一时间只有对应类型的字段生效:
func Text(content string) Message
func ColorText(segments ...TextSegment) Message
func Markdown(title, content string) Message
func Card(content CardContent) Message
调用示例:
message := notice.ColorText(
notice.TextSegment{Text: "状态:"},
notice.TextSegment{
Text: "失败",
Color: notice.ColorDanger,
Bold: true,
},
)
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 对外接口
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 缓存
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。
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:"当前渠道配置"`
}
发送流程:
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 组件。 - 卡片:渲染为
interactiveCard 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,映射为<font color="info|comment|warning">。 - 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 直接发送统一结构:
{
"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. 同步与异步
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 组合。原有控制台、文件或其他日志输出保持不变;只有匹配的日志额外发送消息通知。
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 数据结构
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 等级匹配
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。
type networkClient interface {
Post(request networkRequest) (networkResponse, error)
}
- 每次发送只执行一次 HTTP 请求。
- 使用 Config.Timeout 控制超时。
- 不关闭 TLS 证书校验。
- 错误和日志不输出完整 Webhook、Token 或签名密钥。
- 具体构造函数以锁定版本的 network package API 为准。
- 单元测试通过 Fake Network Client 验证请求体。
12. 错误定义
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. 建议代码结构
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和dctag,运行时字段均为json:"-"。 - 各 Renderer 对四种消息类型生成正确的官方请求结构。
- 企微六种语义颜色正确收敛到三种官方颜色。
- 钉钉带颜色文本降级后不丢失文字。
- 飞书 Card JSON 2.0 的主题、字段和按钮映射正确。
- 所有
CardTheme枚举都能正确写入飞书 Headertemplate。 - 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 原有日志输出不受影响,消息通知默认异步执行。