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" ) // 通知渠道 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(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 发送飞书测试通知 func sendFeishuTest(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") } // sendFeishuAlert 发送飞书告警通知 func sendFeishuAlert(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 return sendFeishuCard(cfg.URL, cfg.Secret, title, content, color) } // sendFeishuRecovery 发送飞书恢复通知 func sendFeishuRecovery(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 return sendFeishuCard(cfg.URL, cfg.Secret, title, content, "green") } // sendFeishuCard 发送飞书交互卡片消息 func sendFeishuCard(webhookURL, secret, title, content, color string) error { // 解析 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": fmt.Sprintf("⏰ %s | AIOps 智能运维平台", time.Now().Format("2006-01-02 15:04:05")), }, }, }, }, }, } 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 := 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 []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(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"), ) }