feat(alert-center): 添加飞书Webhook通知渠道 + 告警引擎实时通知
- 后端: NotifyChannel 模型新增 feishu 类型, FeishuWebhookConfig - 后端: TestNotifyChannel 真实发送飞书测试消息(文本格式) - 后端: 告警引擎触发/恢复时自动发送飞书通知(sendFeishuAlert/sendFeishuRecovery) - 后端: AlertEngine 新增 tcp_time_wait/tcp_close_wait/service_process 指标检测 - 后端: NetworkStatModel 新增 TCPCloseWait 字段 - 后端: QuickSetupRules 一键创建6条告警规则(CPU/内存/磁盘/TIME_WAIT/CLOSE_WAIT/进程) - 后端: 注册 /alerts/quick-setup 路由 - 前端: AlertChannelDialog 新增飞书类型选项 - 前端: helpers 新增 getChannelIcon/getChannelTypeLabel 支持 feishu/dingtalk/wechat
This commit is contained in:
1 parent
7bc5169e8f
commit
5333f6f04a
30 files changed
+2406
-1001
No files matched your search
@@ -1,8 +1,16 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/hmac"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"ezops/internal/models"
|
||||
|
||||
@@ -163,9 +171,9 @@ func (h *AlertHandler) DeleteNotifyChannel(c *gin.Context) {
|
||||
})
|
||||
}
|
||||
|
||||
// TestNotifyChannel 测试通知渠道 // Logan.Li
|
||||
// TestNotifyChannel 测试通知渠道 — 真实发送测试消息 // Logan.Li
|
||||
// @Summary 测试通知渠道连通性
|
||||
// @Description 模拟测试通知渠道(返回测试状态)
|
||||
// @Description 向渠道发送测试消息,验证连通性
|
||||
// @Tags 告警
|
||||
// @Produce json
|
||||
// @Security Bearer
|
||||
@@ -184,12 +192,22 @@ func (h *AlertHandler) TestNotifyChannel(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
// 模拟测试:校验配置非空即视为成功
|
||||
status := "success"
|
||||
message := "测试通知已发送"
|
||||
if channel.Config == "" {
|
||||
status = "warning"
|
||||
message = "渠道未配置参数,请先完善配置"
|
||||
|
||||
switch channel.Type {
|
||||
case "feishu":
|
||||
err := sendFeishuTest(channel.Config)
|
||||
if err != nil {
|
||||
status = "error"
|
||||
message = "飞书通知发送失败: " + err.Error()
|
||||
}
|
||||
default:
|
||||
// 非飞书渠道仅检查配置非空
|
||||
if channel.Config == "" {
|
||||
status = "warning"
|
||||
message = "渠道未配置参数,请先完善配置"
|
||||
}
|
||||
}
|
||||
|
||||
h.db.Model(&channel).Update("test_status", status)
|
||||
@@ -204,3 +222,201 @@ func (h *AlertHandler) TestNotifyChannel(c *gin.Context) {
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// ==================== 飞书 Webhook 发送逻辑 ====================
|
||||
|
||||
// sendFeishuTest 发送飞书测试通知
|
||||
func sendFeishuTest(configJSON string) error {
|
||||
var cfg models.FeishuWebhookConfig
|
||||
if err := json.Unmarshal([]byte(configJSON), &cfg); err != nil {
|
||||
return fmt.Errorf("解析配置失败: %w", err)
|
||||
}
|
||||
if cfg.URL == "" {
|
||||
return fmt.Errorf("webhook URL 为空")
|
||||
}
|
||||
|
||||
title := "✅ AIOps 告警测试通知"
|
||||
content := fmt.Sprintf("通知渠道连通性测试成功\\n测试时间:%s\\n来源:AIOps 智能运维平台", time.Now().Format("2006-01-02 15:04:05"))
|
||||
|
||||
return sendFeishuCard(cfg.URL, cfg.Secret, title, content, "green")
|
||||
}
|
||||
|
||||
// sendFeishuAlert 发送飞书告警通知
|
||||
func sendFeishuAlert(configJSON, title, content, severity string) error {
|
||||
var cfg models.FeishuWebhookConfig
|
||||
if err := json.Unmarshal([]byte(configJSON), &cfg); err != nil {
|
||||
return fmt.Errorf("解析配置失败: %w", err)
|
||||
}
|
||||
if cfg.URL == "" {
|
||||
return fmt.Errorf("webhook URL 为空")
|
||||
}
|
||||
|
||||
color := "red"
|
||||
if severity == "warning" {
|
||||
color = "orange"
|
||||
} else if severity == "info" {
|
||||
color = "blue"
|
||||
}
|
||||
|
||||
emoji := "🔴"
|
||||
if severity == "warning" {
|
||||
emoji = "🟠"
|
||||
} else if severity == "info" {
|
||||
emoji = "🔵"
|
||||
}
|
||||
|
||||
title = emoji + " " + title
|
||||
return sendFeishuCard(cfg.URL, cfg.Secret, title, content, color)
|
||||
}
|
||||
|
||||
// sendFeishuRecovery 发送飞书恢复通知
|
||||
func sendFeishuRecovery(configJSON, title, content string) error {
|
||||
var cfg models.FeishuWebhookConfig
|
||||
if err := json.Unmarshal([]byte(configJSON), &cfg); err != nil {
|
||||
return fmt.Errorf("解析配置失败: %w", err)
|
||||
}
|
||||
if cfg.URL == "" {
|
||||
return fmt.Errorf("webhook URL 为空")
|
||||
}
|
||||
|
||||
title = "✅ " + title
|
||||
return sendFeishuCard(cfg.URL, cfg.Secret, title, content, "green")
|
||||
}
|
||||
|
||||
// sendFeishuCard 发送飞书消息(优先使用卡片格式,出错回退到文本)
|
||||
func sendFeishuCard(webhookURL, secret, title, content, color string) error {
|
||||
// 先尝试文本格式(最可靠)
|
||||
textMsg := map[string]interface{}{
|
||||
"msg_type": "text",
|
||||
"content": map[string]interface{}{
|
||||
"text": title + "\n\n" + content,
|
||||
},
|
||||
}
|
||||
body, _ := json.Marshal(textMsg)
|
||||
|
||||
req, err := http.NewRequest("POST", webhookURL, bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return fmt.Errorf("创建请求失败: %w", err)
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
// 如果有 secret,生成签名
|
||||
if secret != "" {
|
||||
timestamp := time.Now().Unix()
|
||||
sign := genFeishuSign(timestamp, secret)
|
||||
q := req.URL.Query()
|
||||
q.Set("timestamp", fmt.Sprintf("%d", timestamp))
|
||||
q.Set("sign", sign)
|
||||
req.URL.RawQuery = q.Encode()
|
||||
}
|
||||
|
||||
client := &http.Client{Timeout: 10 * time.Second}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return fmt.Errorf("发送请求失败: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
respBody, _ := io.ReadAll(resp.Body)
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return fmt.Errorf("飞书返回错误 (HTTP %d): %s", resp.StatusCode, string(respBody))
|
||||
}
|
||||
|
||||
// 检查飞书业务返回码
|
||||
var result struct {
|
||||
Code int `json:"code"`
|
||||
Msg string `json:"msg"`
|
||||
}
|
||||
json.Unmarshal(respBody, &result)
|
||||
if result.Code != 0 {
|
||||
return fmt.Errorf("飞书返回错误 (code=%d): %s", result.Code, result.Msg)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// buildFeishuCard 构建飞书卡片消息体(保留备用,当前使用文本格式)
|
||||
func buildFeishuCard(title, content, color string) models.FeishuCardMessage {
|
||||
return models.FeishuCardMessage{
|
||||
MsgType: "interactive",
|
||||
Content: models.FeishuCardContent{
|
||||
Title: title,
|
||||
Content: [][]models.FeishuCardElement{
|
||||
{
|
||||
{
|
||||
Tag: "markdown",
|
||||
Text: content,
|
||||
},
|
||||
},
|
||||
{
|
||||
{
|
||||
Tag: "note",
|
||||
Text: fmt.Sprintf("⏰ %s | AIOps 智能运维平台", time.Now().Format("2006-01-02 15:04:05")),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// genFeishuSign 生成飞书签名
|
||||
func genFeishuSign(timestamp int64, secret string) string {
|
||||
stringToSign := fmt.Sprintf("%d\n%s", timestamp, secret)
|
||||
h := hmac.New(sha256.New, []byte(stringToSign))
|
||||
signature := base64.StdEncoding.EncodeToString(h.Sum(nil))
|
||||
return signature
|
||||
}
|
||||
|
||||
// ==================== 批量通知发送 ====================
|
||||
|
||||
// NotifyAlert 发送告警通知到所有启用的渠道
|
||||
func (h *AlertHandler) NotifyAlert(ruleName, hostname, metric, severity, message string, value, threshold float64) {
|
||||
var channels []models.NotifyChannel
|
||||
h.db.Where("enabled = ?", true).Find(&channels)
|
||||
|
||||
for _, ch := range channels {
|
||||
switch ch.Type {
|
||||
case "feishu":
|
||||
title := buildAlertTitle(ruleName, hostname, metric, severity)
|
||||
content := buildAlertContent(ruleName, hostname, metric, severity, message, value, threshold)
|
||||
if err := sendFeishuAlert(ch.Config, title, content, severity); err != nil {
|
||||
fmt.Printf("[AlertNotify] 飞书通知失败 (渠道:%s): %v\n", ch.Name, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// NotifyRecovery 发送恢复通知到所有启用的渠道
|
||||
func (h *AlertHandler) NotifyRecovery(ruleName, hostname, metric, message string) {
|
||||
var channels []models.NotifyChannel
|
||||
h.db.Where("enabled = ?", true).Find(&channels)
|
||||
|
||||
for _, ch := range channels {
|
||||
switch ch.Type {
|
||||
case "feishu":
|
||||
title := fmt.Sprintf("%s — 已恢复正常", ruleName)
|
||||
content := fmt.Sprintf("**告警已恢复**\\n\\n主机:%s\\n指标:%s\\n详情:%s\\n恢复时间:%s",
|
||||
hostname, metric, message, time.Now().Format("2006-01-02 15:04:05"))
|
||||
if err := sendFeishuRecovery(ch.Config, title, content); err != nil {
|
||||
fmt.Printf("[AlertNotify] 飞书恢复通知失败 (渠道:%s): %v\n", ch.Name, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func buildAlertTitle(ruleName, hostname, metric, severity string) string {
|
||||
sevLabel := "严重告警"
|
||||
if severity == "warning" {
|
||||
sevLabel = "警告"
|
||||
} else if severity == "info" {
|
||||
sevLabel = "信息"
|
||||
}
|
||||
return fmt.Sprintf("[%s] %s — %s (%s)", sevLabel, ruleName, hostname, metric)
|
||||
}
|
||||
|
||||
func buildAlertContent(ruleName, hostname, metric, severity, message string, value, threshold float64) string {
|
||||
return fmt.Sprintf(
|
||||
"**规则名称:** %s\\n**目标主机:** %s\\n**指标类型:** %s\\n**告警级别:** %s\\n**当前值:** %.2f\\n**阈值:** %.2f\\n**详细信息:** %s\\n**触发时间:** %s",
|
||||
ruleName, hostname, metric, severity, value, threshold, message, time.Now().Format("2006-01-02 15:04:05"),
|
||||
)
|
||||
}
|
||||
Reference in new issue
Block a user