Files
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

147 lines
4.0 KiB
Go

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
}