Files
LokiStack/backend/services/notify_channel.go
T
opsx f3733f0bfc feat: 品牌统一 LokiStack + 全站中英 i18n + 用户批量删除 + 主题定时切换与浅色侧栏
- 品牌: 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 未翻译、词典双逗号等
2026-10-27 17:11:38 +08:00

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
}