package services // 飞书/Lark 群机器人通知 — 卡片消息, // webhook 存 SystemSetting(AES 加密),对外只回 hasWebhook。 import ( "bytes" "crypto/hmac" "crypto/sha256" "encoding/base64" "encoding/json" "fmt" "net/http" "strings" "time" "lokistack/models" "lokistack/pkg/crypto" "lokistack/repository" ) const ( notifySettingKey = "notify_settings" notifyHTTPTimeout = 10 * time.Second ) // NotifyConfig 通知配置(webhook/签名 加密存储) type NotifyConfig struct { Enabled bool `json:"enabled"` Webhook string `json:"webhook"` // ENC: 密文存储 SigningSecret string `json:"signingSecret"` // 飞书签名校验(可选) } // NotifyPublicConfig 对外输出形态 type NotifyPublicConfig struct { Enabled bool `json:"enabled"` HasWebhook bool `json:"hasWebhook"` HasSecret bool `json:"hasSecret"` } func GetNotifyConfig() *NotifyConfig { db := repository.GetDB() db.RLock() var raw string for i := range db.Settings { if db.Settings[i].Key == notifySettingKey { raw = db.Settings[i].Value break } } db.RUnlock() cfg := &NotifyConfig{} if raw != "" { _ = json.Unmarshal([]byte(raw), cfg) } if c, err := DecryptCred(cfg.Webhook); err == nil { cfg.Webhook = c } if c, err := DecryptCred(cfg.SigningSecret); err == nil { cfg.SigningSecret = c } return cfg } func SaveNotifyConfig(enabled bool, webhook, secret string) error { // 留空 = 保留旧值 old := GetNotifyConfig() 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 } data, _ := json.Marshal(&NotifyConfig{Enabled: enabled, Webhook: webhook, SigningSecret: secret}) db := repository.GetDB() newID := db.NextID("settings") db.Lock() defer db.Unlock() for i := range db.Settings { if db.Settings[i].Key == notifySettingKey { db.Settings[i].Value = string(data) db.RequestSave() return nil } } db.Settings = append(db.Settings, models.SystemSetting{ID: newID, Key: notifySettingKey, Value: string(data)}) db.RequestSave() return nil } // larkSign 飞书签名:timestamp + "\n" + secret 的 HMAC-SHA256,base64 func larkSign(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)) } // SendLarkCard 发送飞书卡片;title 带颜色(red/orange),lines 为 markdown 内容行 func SendLarkCard(title, color string, lines []string) error { cfg := GetNotifyConfig() if !cfg.Enabled || cfg.Webhook == "" { return fmt.Errorf("通知未启用或未配置 webhook") } content := strings.Join(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 cfg.SigningSecret != "" { ts := time.Now().Unix() payload["timestamp"] = fmt.Sprintf("%d", ts) payload["sign"] = larkSign(cfg.SigningSecret, ts) } body, _ := json.Marshal(payload) resp, err := (&http.Client{Timeout: notifyHTTPTimeout}).Post(cfg.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 }