初始提交:OCI 面板后端(含 GenAI 网关一期)

This commit is contained in:
Wang Defa
2026-07-09 15:31:04 +08:00
commit b9a3e97e84
168 changed files with 31794 additions and 0 deletions
+209
View File
@@ -0,0 +1,209 @@
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 Bot API 推送通知;未启用或未配置时静默跳过。
// 云外 HTTP 调用,不属于 internal/oci 的职责范围。
type Notifier struct {
settings *SettingService
client *http.Client
base string // 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 {
cfg, err := n.settings.TelegramConfig(ctx)
if err != nil {
return err
}
if !cfg.Enabled || cfg.BotToken == "" || cfg.ChatID == "" {
return nil
}
return n.post(ctx, cfg, text)
}
// 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("telegram 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("telegram 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)
}
cfg, err := n.settings.TelegramConfig(ctx)
if err != nil {
return err
}
if !cfg.Enabled || cfg.BotToken == "" || cfg.ChatID == "" {
return fmt.Errorf("Telegram 通知未启用或未配置,请先在「通知方式」保存配置")
}
vars := map[string]string{}
for k, v := range def.Sample {
vars[k] = v
}
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)
if err != nil {
return err
}
if !cfg.Enabled || cfg.BotToken == "" || cfg.ChatID == "" {
return nil
}
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.postHTML(ctx, cfg, mdToTelegramHTML(truncateText(text, notifyTextLimit)))
}
// 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]) + "…"
}