Files
Wang Defa dbba1f4905
CI / test (push) Successful in 30s
Release / release (push) Successful in 49s
发布 0.1.0:通知渠道、告警规则、令牌版本与安全加固
2026-07-10 17:38:34 +08:00

326 lines
10 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package service
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"html"
"log"
"net/http"
"net/url"
"strings"
"sync"
"time"
)
// notifySendTimeout 是单条通知发送的超时时间。
const notifySendTimeout = 10 * time.Second
// notifyTextLimit 是通知正文长度上限(Telegram 上限 4096 字符,预留转义余量)。
const notifyTextLimit = 3800
// telegramAPIBase 是 Telegram Bot API 官方根地址。
const telegramAPIBase = "https://api.telegram.org"
// Notifier 向 Telegram 与全部启用的通知渠道(webhook/ntfy/bark/smtp)推送;
// 未启用或未配置的渠道静默跳过。云外 HTTP 调用,不属于 internal/oci 的职责范围。
type Notifier struct {
settings *SettingService
client *http.Client
base string // Telegram API 根地址,测试时注入 httptest 服务
wg sync.WaitGroup
}
// NewNotifier 组装依赖,指向官方 API 地址。
func NewNotifier(settings *SettingService) *Notifier {
return &Notifier{
settings: settings,
client: &http.Client{Timeout: notifySendTimeout},
base: telegramAPIBase,
}
}
// 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
}
if !cfg.Enabled || cfg.BotToken == "" || cfg.ChatID == "" {
return nil
}
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 异步发送并自带超时,失败只记日志,绝不影响调用链路。
func (n *Notifier) SendAsync(text string) {
n.wg.Add(1)
go func() {
defer n.wg.Done()
ctx, cancel := context.WithTimeout(context.Background(), notifySendTimeout)
defer cancel()
if err := n.Send(ctx, text); err != nil {
log.Printf("notify: %v", err)
}
}()
}
// SendTemplateAsync 按 kind 的生效模板(自定义或默认)渲染变量后异步发送;
// 模板支持轻量 Markdown,Telegram 按 HTML 语义投递,其余渠道按纯文本投递。
func (n *Notifier) SendTemplateAsync(kind string, vars map[string]string) {
n.wg.Add(1)
go func() {
defer n.wg.Done()
ctx, cancel := context.WithTimeout(context.Background(), notifySendTimeout)
defer cancel()
if err := n.sendTemplate(ctx, kind, "", vars); err != nil {
log.Printf("notify %s: %v", kind, err)
}
}()
}
// SendTemplateTest 用 kind 的示例变量同步发送一条测试消息;
// tplOverride 非空时按给定模板渲染(供编辑表单预览未保存内容)。
func (n *Notifier) SendTemplateTest(ctx context.Context, kind, tplOverride string) error {
def, ok := notifyTplDefs[kind]
if !ok {
return fmt.Errorf("未知通知类型 %q", kind)
}
enabled, err := n.anyChannelEnabled(ctx)
if err != nil {
return err
}
if !enabled {
return fmt.Errorf("没有已启用的通知渠道,请先在「通知方式」配置并启用")
}
vars := map[string]string{}
for k, v := range def.Sample {
vars[k] = v
}
return n.sendTemplate(ctx, kind, tplOverride, vars)
}
// anyChannelEnabled 报告是否存在至少一个已启用且配置齐全的渠道(含 Telegram)。
func (n *Notifier) anyChannelEnabled(ctx context.Context) (bool, error) {
tg, err := n.settings.TelegramConfig(ctx)
if err != nil {
return false, err
}
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)
if err != nil {
log.Printf("notify template %s: %v", kind, err)
}
tpl = notifyTplText(custom, kind)
}
text := renderNotifyTemplate(tpl, vars)
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 等待全部在途异步通知发完,供退出前收尾与测试同步。
func (n *Notifier) Wait() {
n.wg.Wait()
}
// Test 同步发送一条测试消息,配置无效时透出可读错误。
func (n *Notifier) Test(ctx context.Context) error {
cfg, err := n.settings.TelegramConfig(ctx)
if err != nil {
return err
}
if cfg.BotToken == "" || cfg.ChatID == "" {
return fmt.Errorf("telegram test: bot token and chat id are required")
}
return n.post(ctx, cfg, "✅ oci-portal 测试消息:Telegram 通知配置成功")
}
// post 调用 sendMessage;先截断再做 HTML 转义,避免把转义实体截成两半。
func (n *Notifier) post(ctx context.Context, cfg TelegramConfig, text string) error {
return n.postHTML(ctx, cfg, html.EscapeString(truncateText(text, notifyTextLimit)))
}
// postHTML 调用 sendMessage 发送已按 Telegram HTML 组装的文本(调用方保证转义)。
func (n *Notifier) postHTML(ctx context.Context, cfg TelegramConfig, htmlText string) error {
body, err := json.Marshal(map[string]any{
"chat_id": cfg.ChatID,
"text": htmlText,
"parse_mode": "HTML",
"disable_web_page_preview": true,
})
if err != nil {
return fmt.Errorf("telegram send: %w", err)
}
apiURL := fmt.Sprintf("%s/bot%s/sendMessage", n.base, cfg.BotToken)
req, err := http.NewRequestWithContext(ctx, http.MethodPost, apiURL, bytes.NewReader(body))
if err != nil {
return fmt.Errorf("telegram send: %w", sanitizeURLError(err))
}
req.Header.Set("Content-Type", "application/json")
resp, err := n.client.Do(req)
if err != nil {
return fmt.Errorf("telegram send: %w", sanitizeURLError(err))
}
defer resp.Body.Close()
return parseTelegramResponse(resp)
}
// telegramResponse 是 Telegram Bot API 的通用响应包。
type telegramResponse struct {
OK bool `json:"ok"`
Description string `json:"description"`
}
// parseTelegramResponse 解析响应;ok=false 时透出 Telegram 的错误描述。
func parseTelegramResponse(resp *http.Response) error {
var r telegramResponse
if err := json.NewDecoder(resp.Body).Decode(&r); err != nil {
return fmt.Errorf("telegram response (%d): %w", resp.StatusCode, err)
}
if !r.OK {
return fmt.Errorf("telegram: %s", r.Description)
}
return nil
}
// sanitizeURLError 剥掉 *url.Error 外壳只保留底层原因:
// 其 Error() 会拼出完整请求 URL,而路径中含 bot token
// 原样向上返回会把 token 泄漏进日志与接口响应。
func sanitizeURLError(err error) error {
var uerr *url.Error
if errors.As(err, &uerr) {
return uerr.Err
}
return err
}
// truncateText 按 rune 截断超长文本,追加省略号提示。
func truncateText(text string, limit int) string {
runes := []rune(text)
if len(runes) <= limit {
return text
}
return string(runes[:limit]) + "…"
}