package services // 多通知通道(飞书/Lark 群机器人)CRUD 与按通道发送。 // 取代旧的单 webhook 全局配置:每个告警群一个通道,告警规则各自关联。 import ( "bytes" "crypto/hmac" "crypto/sha256" "encoding/base64" "encoding/json" "fmt" "net/http" "time" "lokistack/models" "lokistack/pkg/crypto" "lokistack/repository" ) const channelHTTPTimeout = 10 * time.Second func ListNotifyChannels() []map[string]interface{} { db := repository.GetDB() db.RLock() out := make([]map[string]interface{}, 0, len(db.NotifyChannels)) for i := range db.NotifyChannels { out = append(out, db.NotifyChannels[i].Sanitized()) } db.RUnlock() return out } func GetNotifyChannel(id uint) *models.NotifyChannel { db := repository.GetDB() db.RLock() defer db.RUnlock() for i := range db.NotifyChannels { if db.NotifyChannels[i].ID == id { c := db.NotifyChannels[i] return &c } } return nil } // GetNotifyChannelDecrypted 取通道并解密凭证(发送用) func GetNotifyChannelDecrypted(id uint) *models.NotifyChannel { c := GetNotifyChannel(id) if c == nil { return nil } if w, err := DecryptCred(c.Webhook); err == nil { c.Webhook = w } if s, err := DecryptCred(c.SigningSecret); err == nil { c.SigningSecret = s } return c } // SaveNotifyChannel 新建(id=0)或更新;webhook/secret 非空才更新(留空=保留旧值) func SaveNotifyChannel(id uint, name, webhook, secret string, enabled bool) (*models.NotifyChannel, error) { db := repository.GetDB() // 更新:留空字段回填旧值(旧值已是密文) if id > 0 { db.RLock() var old models.NotifyChannel found := false for i := range db.NotifyChannels { if db.NotifyChannels[i].ID == id { old = db.NotifyChannels[i] found = true break } } db.RUnlock() if !found { return nil, fmt.Errorf("通道不存在") } if webhook == "" { webhook = old.Webhook } else if enc, err := crypto.EncryptField(webhook); err == nil { webhook = enc } if secret == "" { secret = old.SigningSecret } else if enc, err := crypto.EncryptField(secret); err == nil { secret = enc } db.Lock() defer db.Unlock() for i := range db.NotifyChannels { if db.NotifyChannels[i].ID == id { db.NotifyChannels[i].Name = name db.NotifyChannels[i].Webhook = webhook db.NotifyChannels[i].SigningSecret = secret db.NotifyChannels[i].Enabled = enabled db.NotifyChannels[i].UpdatedAt = time.Now() db.RequestSave() c := db.NotifyChannels[i] return &c, nil } } return nil, fmt.Errorf("通道不存在") } // 新建 if name == "" { return nil, fmt.Errorf("通道名称不能为空") } if webhook == "" { return nil, fmt.Errorf("webhook 不能为空") } if enc, err := crypto.EncryptField(webhook); err == nil { webhook = enc } if secret != "" { if enc, err := crypto.EncryptField(secret); err == nil { secret = enc } } newID := db.NextID("notifyChannels") ch := &models.NotifyChannel{ ID: newID, Name: name, Webhook: webhook, SigningSecret: secret, Enabled: enabled, } db.Lock() defer db.Unlock() db.NotifyChannels = append(db.NotifyChannels, *ch) db.RequestSave() return ch, nil } func DeleteNotifyChannel(id uint) error { // 有关联规则时拒绝删除 db := repository.GetDB() db.RLock() for i := range db.AlertRules { for _, chID := range db.AlertRules[i].ChannelIDs { if chID == id { db.RUnlock() return fmt.Errorf("仍有告警规则关联此通道,请先解除关联") } } } db.RUnlock() db.Lock() defer db.Unlock() for i := range db.NotifyChannels { if db.NotifyChannels[i].ID == id { db.NotifyChannels = append(db.NotifyChannels[:i], db.NotifyChannels[i+1:]...) db.RequestSave() return nil } } return fmt.Errorf("通道不存在") } // larkSignChannel 飞书签名 func larkSignChannel(secret string, ts int64) string { stringToSign := fmt.Sprintf("%d\n%s", ts, secret) h := hmac.New(sha256.New, []byte(stringToSign)) return base64.StdEncoding.EncodeToString(h.Sum(nil)) } // SendLarkCardToChannel 向指定通道发送卡片 func SendLarkCardToChannel(channelID uint, title, color string, lines []string) error { ch := GetNotifyChannelDecrypted(channelID) if ch == nil { return fmt.Errorf("通知通道不存在") } if !ch.Enabled { return fmt.Errorf("通道「%s」已禁用", ch.Name) } content := joinNonEmpty(lines, "\n") payload := map[string]interface{}{ "msg_type": "interactive", "card": map[string]interface{}{ "config": map[string]interface{}{"wide_screen_mode": true}, "header": map[string]interface{}{ "title": map[string]string{"tag": "plain_text", "content": title}, "template": color, }, "elements": []interface{}{ map[string]interface{}{"tag": "div", "text": map[string]string{"tag": "lark_md", "content": content}}, map[string]interface{}{"tag": "note", "elements": []interface{}{map[string]string{"tag": "plain_text", "content": "LokiStack 日志告警"}}}, }, }, } if ch.SigningSecret != "" { ts := time.Now().Unix() payload["timestamp"] = fmt.Sprintf("%d", ts) payload["sign"] = larkSignChannel(ch.SigningSecret, ts) } body, _ := json.Marshal(payload) resp, err := (&http.Client{Timeout: channelHTTPTimeout}).Post(ch.Webhook, "application/json", bytes.NewReader(body)) if err != nil { return err } defer resp.Body.Close() if resp.StatusCode != 200 { return fmt.Errorf("飞书返回 HTTP %d", resp.StatusCode) } var out struct { Code int `json:"code"` Msg string `json:"msg"` } _ = json.NewDecoder(resp.Body).Decode(&out) if out.Code != 0 { return fmt.Errorf("飞书拒绝: %s", out.Msg) } return nil } func joinNonEmpty(items []string, sep string) string { result := "" for _, item := range items { if item == "" { continue } if result != "" { result += sep } result += item } return result }