Files
2026-07-09 14:16:56 +08:00

476 lines
12 KiB
Go
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 controller
import (
"bytes"
"crypto/hmac"
"crypto/sha256"
"encoding/base64"
"encoding/json"
"fmt"
"io"
"net/http"
"strconv"
"strings"
"time"
"ezops/model"
"github.com/gin-gonic/gin"
"gorm.io/gorm"
)
// 通知渠道 CRUD(AlertHandler 方法接收者,与 alert.go 同包共享) // Logan.Li
// ListNotifyChannels 获取通知渠道列表 // Logan.Li
// @Summary 获取通知渠道列表
// @Description 获取所有通知渠道列表
// @Tags 告警
// @Produce json
// @Security Bearer
// @Success 200 {object} model.Response{data=model.ListResponse}
// @Router /alerts/notify-channels [get]
func (h *AlertHandler) ListNotifyChannels(c *gin.Context) {
var channels []model.NotifyChannel
h.db.Order("created_at DESC").Find(&channels)
c.JSON(http.StatusOK, model.Response{
Code: 200,
Message: "success",
Data: model.ListResponse{
List: channels,
Total: int64(len(channels)),
},
})
}
// CreateNotifyChannel 创建通知渠道 // Logan.Li
// @Summary 创建通知渠道
// @Description 创建新通知渠道
// @Tags 告警
// @Accept json
// @Produce json
// @Security Bearer
// @Param request body model.CreateNotifyChannelRequest true "创建请求"
// @Success 200 {object} model.Response{data=model.NotifyChannel}
// @Router /alerts/notify-channels [post]
func (h *AlertHandler) CreateNotifyChannel(c *gin.Context) {
var req model.CreateNotifyChannelRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, model.Response{
Code: 400,
Message: "请求参数错误: " + err.Error(),
})
return
}
channel := model.NotifyChannel{
Name: req.Name,
Type: req.Type,
Config: req.Config,
Enabled: true,
}
if err := h.db.Create(&channel).Error; err != nil {
c.JSON(http.StatusInternalServerError, model.Response{
Code: 500,
Message: "创建失败",
})
return
}
c.JSON(http.StatusOK, model.Response{
Code: 200,
Message: "创建成功",
Data: channel,
})
}
// UpdateNotifyChannel 更新通知渠道 // Logan.Li
// @Summary 更新通知渠道
// @Description 更新指定通知渠道
// @Tags 告警
// @Accept json
// @Produce json
// @Security Bearer
// @Param id path int true "渠道ID"
// @Param request body model.UpdateNotifyChannelRequest true "更新请求"
// @Success 200 {object} model.Response{data=model.NotifyChannel}
// @Router /alerts/notify-channels/{id} [put]
func (h *AlertHandler) UpdateNotifyChannel(c *gin.Context) {
id, _ := strconv.Atoi(c.Param("id"))
var req model.UpdateNotifyChannelRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, model.Response{
Code: 400,
Message: "请求参数错误: " + err.Error(),
})
return
}
var channel model.NotifyChannel
if err := h.db.First(&channel, id).Error; err != nil {
c.JSON(http.StatusNotFound, model.Response{
Code: 404,
Message: "通知渠道不存在",
})
return
}
updates := map[string]interface{}{}
if req.Name != "" {
updates["name"] = req.Name
}
if req.Type != "" {
updates["type"] = req.Type
}
if req.Config != "" {
updates["config"] = req.Config
}
if req.Enabled != nil {
updates["enabled"] = *req.Enabled
}
if err := h.db.Model(&channel).Updates(updates).Error; err != nil {
c.JSON(http.StatusInternalServerError, model.Response{
Code: 500,
Message: "更新失败",
})
return
}
h.db.First(&channel, id)
c.JSON(http.StatusOK, model.Response{
Code: 200,
Message: "更新成功",
Data: channel,
})
}
// DeleteNotifyChannel 删除通知渠道 // Logan.Li
// @Summary 删除通知渠道
// @Description 删除指定通知渠道
// @Tags 告警
// @Produce json
// @Security Bearer
// @Param id path int true "渠道ID"
// @Success 200 {object} model.Response
// @Router /alerts/notify-channels/{id} [delete]
func (h *AlertHandler) DeleteNotifyChannel(c *gin.Context) {
id, _ := strconv.Atoi(c.Param("id"))
if err := h.db.Delete(&model.NotifyChannel{}, id).Error; err != nil {
c.JSON(http.StatusInternalServerError, model.Response{
Code: 500,
Message: "删除失败",
})
return
}
c.JSON(http.StatusOK, model.Response{
Code: 200,
Message: "删除成功",
})
}
// TestNotifyChannel 测试通知渠道 — 真实发送测试消息 // Logan.Li
// @Summary 测试通知渠道连通性
// @Description 向渠道发送测试消息,验证连通性
// @Tags 告警
// @Produce json
// @Security Bearer
// @Param id path int true "渠道ID"
// @Success 200 {object} model.Response
// @Router /alerts/notify-channels/{id}/test [post]
func (h *AlertHandler) TestNotifyChannel(c *gin.Context) {
id, _ := strconv.Atoi(c.Param("id"))
var channel model.NotifyChannel
if err := h.db.First(&channel, id).Error; err != nil {
c.JSON(http.StatusNotFound, model.Response{
Code: 404,
Message: "通知渠道不存在",
})
return
}
status := "success"
message := "测试通知已发送"
switch channel.Type {
case "feishu":
err := sendFeishuTest(h.db, 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)
c.JSON(http.StatusOK, model.Response{
Code: 200,
Message: message,
Data: gin.H{
"status": status,
"channel": channel.Name,
"type": channel.Type,
},
})
}
// ==================== 飞书 Webhook 发送逻辑 ====================
// sendFeishuTest 发送飞书测试通知 // Logan.Li
func sendFeishuTest(db *gorm.DB, configJSON string) error {
var cfg model.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("**测试时间**:%s\\n**来源**:AIOps 智能运维平台\\n**状态**:连通性测试成功 🎉", time.Now().Format("2006-01-02 15:04:05"))
return sendFeishuCard(cfg.URL, cfg.Secret, title, content, "green", buildNotifyFooter(db))
}
// sendFeishuAlert 发送飞书告警通知 // Logan.Li
func sendFeishuAlert(db *gorm.DB, configJSON, title, content, severity string) error {
var cfg model.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
footer := defaultNotifyFooterTemplate
if db != nil {
footer = buildNotifyFooter(db)
}
return sendFeishuCard(cfg.URL, cfg.Secret, title, content, color, footer)
}
// sendFeishuRecovery 发送飞书恢复通知 // Logan.Li
func sendFeishuRecovery(db *gorm.DB, configJSON, title, content string) error {
var cfg model.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
footer := buildNotifyFooter(db)
return sendFeishuCard(cfg.URL, cfg.Secret, title, content, "green", footer)
}
// sendFeishuCard 发送飞书交互卡片消息 // Logan.Li
func sendFeishuCard(webhookURL, secret, title, content, color, footer string) error {
if footer == "" {
footer = fmt.Sprintf("%s | %s", time.Now().Format("2006-01-02 15:04:05"), defaultNotifyPlatformName)
}
// 解析 content 中的字段(格式: 多行 key: value)
lines := splitLines(content)
var fields []map[string]interface{}
for _, line := range lines {
line = strings.TrimSpace(line)
if line == "" {
continue
}
fields = append(fields, map[string]interface{}{
"is_short": false,
"text": map[string]interface{}{
"tag": "lark_md",
"content": line,
},
})
}
template := color
if template == "red" {
template = "red"
} else if template == "orange" || template == "warning" {
template = "orange"
} else if template == "green" {
template = "green"
} else if template == "blue" || template == "info" {
template = "blue"
} else {
template = "red"
}
card := map[string]interface{}{
"msg_type": "interactive",
"card": map[string]interface{}{
"header": map[string]interface{}{
"title": map[string]interface{}{
"tag": "plain_text",
"content": title,
},
"template": template,
},
"elements": []map[string]interface{}{
{
"tag": "div",
"fields": fields,
},
{
"tag": "hr",
},
{
"tag": "note",
"elements": []map[string]interface{}{
{
"tag": "plain_text",
"content": footer,
},
},
},
},
},
}
body, _ := json.Marshal(card)
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,追加签名到 URL query
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
}
// splitLines splits a string by newlines, handling both actual \n and escaped \\n
func splitLines(s string) []string {
// First replace literal \n (from Go format strings) with actual newlines
s = strings.ReplaceAll(s, "\\n", "\n")
return strings.Split(s, "\n")
}
// buildFeishuCard 构建飞书卡片消息体(保留备用,当前使用文本格式)
func buildFeishuCard(title, content, color string) model.FeishuCardMessage {
return model.FeishuCardMessage{
MsgType: "interactive",
Content: model.FeishuCardContent{
Title: title,
Content: [][]model.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 []model.NotifyChannel
h.db.Where("enabled = ?", true).Find(&channels)
for _, ch := range channels {
switch ch.Type {
case "feishu":
title := buildNotifyTitle(h.db, ruleName, hostname, metric, severity)
content := buildAlertContent(ruleName, hostname, metric, severity, message, value, threshold)
if err := sendFeishuAlert(h.db, 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 []model.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(h.db, ch.Config, title, content); err != nil {
fmt.Printf("[AlertNotify] 飞书恢复通知失败 (渠道:%s): %v\n", ch.Name, err)
}
}
}
}