发布 0.1.0:通知渠道、告警规则、令牌版本与安全加固
CI / test (push) Successful in 30s
Release / release (push) Successful in 49s

This commit is contained in:
Wang Defa
2026-07-10 17:38:34 +08:00
parent 4af6a0ca92
commit dbba1f4905
78 changed files with 6898 additions and 551 deletions
+134 -18
View File
@@ -24,12 +24,12 @@ const notifyTextLimit = 3800
// telegramAPIBase 是 Telegram Bot API 官方根地址。
const telegramAPIBase = "https://api.telegram.org"
// Notifier 通过 Telegram Bot API 推送通知;未启用或未配置时静默跳过。
// 云外 HTTP 调用不属于 internal/oci 的职责范围。
// Notifier Telegram 与全部启用的通知渠道(webhook/ntfy/bark/smtp)推送;
// 未启用或未配置的渠道静默跳过。云外 HTTP 调用,不属于 internal/oci 的职责范围。
type Notifier struct {
settings *SettingService
client *http.Client
base string // API 根地址测试时注入 httptest 服务
base string // Telegram API 根地址,测试时注入 httptest 服务
wg sync.WaitGroup
}
@@ -42,8 +42,28 @@ func NewNotifier(settings *SettingService) *Notifier {
}
}
// Send 读取配置并发送一条消息;未启用或未配置时直接返回 nil。
// Send 向全部启用渠道发送一条纯文本消息;无渠道可用时返回 nil。
func (n *Notifier) Send(ctx context.Context, text string) error {
return n.dispatch(ctx, notifyMsg{Title: "oci-portal 通知", Text: text})
}
// dispatch 把消息投递到 Telegram 与全部启用渠道;渠道间互不影响,错误合并返回供日志。
func (n *Notifier) dispatch(ctx context.Context, msg notifyMsg) error {
msg.Text = truncateText(msg.Text, notifyTextLimit)
var errs []error
if err := n.sendTelegram(ctx, msg); err != nil {
errs = append(errs, fmt.Errorf("telegram: %w", err))
}
for _, chType := range NotifyChannelTypes {
if err := n.sendChannel(ctx, chType, msg); err != nil {
errs = append(errs, fmt.Errorf("%s: %w", chType, err))
}
}
return errors.Join(errs...)
}
// sendTelegram 走既有 Telegram 路径;未启用或未配置时静默跳过。
func (n *Notifier) sendTelegram(ctx context.Context, msg notifyMsg) error {
cfg, err := n.settings.TelegramConfig(ctx)
if err != nil {
return err
@@ -51,7 +71,38 @@ func (n *Notifier) Send(ctx context.Context, text string) error {
if !cfg.Enabled || cfg.BotToken == "" || cfg.ChatID == "" {
return nil
}
return n.post(ctx, cfg, text)
return n.postHTML(ctx, cfg, mdToTelegramHTML(msg.Text))
}
// sendChannel 向单个新增渠道投递;未启用或关键字段缺失时静默跳过。
func (n *Notifier) sendChannel(ctx context.Context, chType string, msg notifyMsg) error {
switch chType {
case ChannelWebhook:
cfg, err := n.settings.webhookChannel(ctx)
if err != nil || !cfg.Enabled || cfg.URL == "" {
return err
}
return sendWebhook(ctx, n.client, cfg, msg)
case ChannelNtfy:
cfg, err := n.settings.ntfyChannel(ctx)
if err != nil || !cfg.Enabled || cfg.Topic == "" {
return err
}
return sendNtfy(ctx, n.client, cfg, msg)
case ChannelBark:
cfg, err := n.settings.barkChannel(ctx)
if err != nil || !cfg.Enabled || cfg.DeviceKey == "" {
return err
}
return sendBark(ctx, n.client, cfg, msg)
case ChannelSMTP:
cfg, err := n.settings.smtpChannel(ctx)
if err != nil || !cfg.Enabled || cfg.Host == "" {
return err
}
return sendSMTP(ctx, cfg, msg)
}
return nil
}
// SendAsync 异步发送并自带超时,失败只记日志,绝不影响调用链路。
@@ -62,13 +113,13 @@ func (n *Notifier) SendAsync(text string) {
ctx, cancel := context.WithTimeout(context.Background(), notifySendTimeout)
defer cancel()
if err := n.Send(ctx, text); err != nil {
log.Printf("telegram notify: %v", err)
log.Printf("notify: %v", err)
}
}()
}
// SendTemplateAsync 按 kind 的生效模板(自定义或默认)渲染变量后异步发送;
// 模板支持轻量 Markdown,Telegram HTML 语义投递。
// 模板支持轻量 Markdown,Telegram HTML 语义投递,其余渠道按纯文本投递
func (n *Notifier) SendTemplateAsync(kind string, vars map[string]string) {
n.wg.Add(1)
go func() {
@@ -76,7 +127,7 @@ func (n *Notifier) SendTemplateAsync(kind string, vars map[string]string) {
ctx, cancel := context.WithTimeout(context.Background(), notifySendTimeout)
defer cancel()
if err := n.sendTemplate(ctx, kind, "", vars); err != nil {
log.Printf("telegram notify %s: %v", kind, err)
log.Printf("notify %s: %v", kind, err)
}
}()
}
@@ -88,12 +139,12 @@ func (n *Notifier) SendTemplateTest(ctx context.Context, kind, tplOverride strin
if !ok {
return fmt.Errorf("未知通知类型 %q", kind)
}
cfg, err := n.settings.TelegramConfig(ctx)
enabled, err := n.anyChannelEnabled(ctx)
if err != nil {
return err
}
if !cfg.Enabled || cfg.BotToken == "" || cfg.ChatID == "" {
return fmt.Errorf("Telegram 通知未启用或未配置,请先在「通知方式」保存配置")
if !enabled {
return fmt.Errorf("没有已启用的通知渠道,请先在「通知方式」配置并启用")
}
vars := map[string]string{}
for k, v := range def.Sample {
@@ -102,15 +153,29 @@ func (n *Notifier) SendTemplateTest(ctx context.Context, kind, tplOverride strin
return n.sendTemplate(ctx, kind, tplOverride, vars)
}
// sendTemplate 渲染并发送:取生效模板 → 变量替换 → 截断 → Markdown 转 Telegram HTML
func (n *Notifier) sendTemplate(ctx context.Context, kind, tplOverride string, vars map[string]string) error {
cfg, err := n.settings.TelegramConfig(ctx)
// anyChannelEnabled 报告是否存在至少一个已启用且配置齐全的渠道(含 Telegram)
func (n *Notifier) anyChannelEnabled(ctx context.Context) (bool, error) {
tg, err := n.settings.TelegramConfig(ctx)
if err != nil {
return err
return false, err
}
if !cfg.Enabled || cfg.BotToken == "" || cfg.ChatID == "" {
return nil
if tg.Enabled && tg.BotToken != "" && tg.ChatID != "" {
return true, nil
}
views, err := n.settings.NotifyChannels(ctx)
if err != nil {
return false, err
}
for _, v := range views {
if v.Enabled {
return true, nil
}
}
return false, nil
}
// sendTemplate 渲染并分发:取生效模板 → 变量替换 → 投递到全部启用渠道。
func (n *Notifier) sendTemplate(ctx context.Context, kind, tplOverride string, vars map[string]string) error {
tpl := tplOverride
if strings.TrimSpace(tpl) == "" {
custom, err := n.settings.NotifyTemplate(ctx, kind)
@@ -120,7 +185,58 @@ func (n *Notifier) sendTemplate(ctx context.Context, kind, tplOverride string, v
tpl = notifyTplText(custom, kind)
}
text := renderNotifyTemplate(tpl, vars)
return n.postHTML(ctx, cfg, mdToTelegramHTML(truncateText(text, notifyTextLimit)))
return n.dispatch(ctx, notifyMsg{Title: notifyTplLabel(kind), Text: text})
}
// SendChannelTest 向指定渠道同步发送测试消息;不看启用开关,便于保存后即时验证。
func (n *Notifier) SendChannelTest(ctx context.Context, chType string) error {
msg := notifyMsg{Title: "oci-portal 测试消息", Text: "✅ 通知渠道配置成功:" + chType}
switch chType {
case ChannelWebhook:
cfg, err := n.settings.webhookChannel(ctx)
if err != nil {
return err
}
if cfg.URL == "" {
return fmt.Errorf("请先保存 Webhook URL")
}
return sendWebhook(ctx, n.client, cfg, msg)
case ChannelNtfy:
cfg, err := n.settings.ntfyChannel(ctx)
if err != nil {
return err
}
if cfg.Topic == "" {
return fmt.Errorf("请先保存 ntfy topic")
}
return sendNtfy(ctx, n.client, cfg, msg)
}
return n.sendChannelTestRest(ctx, chType, msg)
}
// sendChannelTestRest 承接 SendChannelTest 的 bark/smtp 分支(拆分控制函数长度)。
func (n *Notifier) sendChannelTestRest(ctx context.Context, chType string, msg notifyMsg) error {
switch chType {
case ChannelBark:
cfg, err := n.settings.barkChannel(ctx)
if err != nil {
return err
}
if cfg.DeviceKey == "" {
return fmt.Errorf("请先保存 Bark device key")
}
return sendBark(ctx, n.client, cfg, msg)
case ChannelSMTP:
cfg, err := n.settings.smtpChannel(ctx)
if err != nil {
return err
}
if cfg.Host == "" || cfg.From == "" || cfg.To == "" {
return fmt.Errorf("请先保存 SMTP 主机、发件人与收件人")
}
return sendSMTP(ctx, cfg, msg)
}
return ErrUnknownNotifyChannel
}
// Wait 等待全部在途异步通知发完,供退出前收尾与测试同步。