Files
aiops/backend/controller/alert_channels.go
T
attacker 77c234b060 feat(audit): F1+F2 告警静默规则标记完成 + 审计日志CSV导出实现
F1 告警静默规则 [done]:
- 后端 alert_silence.go: CRUD + Toggle (onetime/daily/weekly 三模式)
- 告警引擎 isSilenced 集成 (alert_engine.go)
- 前端 SilenceRuleTab + SilenceDialog
- 路由 /alerts/silences 已注册

F2 审计日志导出 [done]:
- 重写 ExportAuditLogs: 真正的 CSV 导出 (encoding/csv + UTF-8 BOM)
- 支持 module/action/username/startTime/endTime 筛选
- 导出限制 10000 条防溢出,自动记录审计日志
- JSON 格式向后兼容 (format=json)
- 前端 exportLogs 传递查询参数

同时包含累积的前期工作:
- 目录重构 internal/ -> controller/model/router (v0.55-v0.57)
- 后端分层架构 dto/repository/service/utils
- 审计模块全功能 (终端审计/操作审计/登录日志)
- asciicast v2 录制格式
- SFTP 文件浏览器
- StatStrip 紧凑统计条
- 在线会话自动刷新
- LiveMonitor WebSocket 诊断修复

验证: go build ✅ | npm run build ✅ | Docker 部署 ✅ | 10 回归端点 ✅ | CSV 导出 ✅ | 静默 CRUD ✅
2026-06-25 20:16:30 +08:00

484 lines
13 KiB
Go
Raw 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"
)
// 通知渠道 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"),
)
}