771 lines
26 KiB
Markdown
771 lines
26 KiB
Markdown
# 多渠道消息通知 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`,映射为 `<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)
|
||
- [Zap:Logger 与 WrapCore](https://pkg.go.dev/go.uber.org/zap)
|
||
- [Zap:zapcore.Core](https://pkg.go.dev/go.uber.org/zap/zapcore#Core)
|