feat: 完成一般消息通知能力的开发

This commit is contained in:
2026-08-12 11:42:08 +08:00
commit 778c46a691
17 changed files with 2470 additions and 0 deletions
+770
View File
@@ -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: &notice.SecurityConfig{
SignEnabled: true,
SignSecret: os.Getenv("FEISHU_SIGN_SECRET"),
},
Timeout: 5 * time.Second,
},
notice.ChannelDingTalk: {
Webhook: os.Getenv("DINGTALK_WEBHOOK"),
Security: &notice.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`,映射为 `<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 直接发送统一结构:
```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)
- [ZapLogger 与 WrapCore](https://pkg.go.dev/go.uber.org/zap)
- [Zapzapcore.Core](https://pkg.go.dev/go.uber.org/zap/zapcore#Core)