Files
notice/多渠道消息通知平台设计文档.md

771 lines
26 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 多渠道消息通知 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)