- 品牌: Go 模块/数据文件/二进制/构建部署资产全部 opsx→lokistack;前端 localStorage 命名空间迁移(读旧写新,含按用户 id 前缀迁移);修复 nginx upstream 与 Makefile 引用已改名服务的断裂 - i18n: react-i18next 落地,common + 11 个页面域命名空间全站中英(菜单/顶栏/登录/列表页/表单/确认弹窗/状态徽章);设置页语言即时切换;修复词典自注册导致的循环导入白屏 - 用户管理: 批量删除(POST /users/batch-delete,不删自己/最后管理员,部分失败不阻断)+ 行复选/全选/批量工具条 - 主题: 浅色侧栏(中性灰激活态)、四态模式(浅色/深色/跟随系统/定时切换,按时区判定)、顶栏太阳月亮二态、设置页紧凑分段选择器 - UI: 登录记住用户名、CardActionButton 统一五页卡片操作钮、shadcn Badge 替换手写徽章、语义色修正 - 清理: 移除主机 Web 终端(前后端)、k8s-helm/Helm 遗留代码与字段、README 重写精简 - 修复: 侧栏与日志告警 order 重复、路由标题 i18n key 未翻译、词典双逗号等
227 lines
5.8 KiB
Go
227 lines
5.8 KiB
Go
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
|
|
}
|