# 多渠道消息通知 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)