Files
lijiaoqiao/gateway/internal/alert/alert.go
Your Name 0484c7be74 feat(gateway): 实现网关核心模块
实现内容:
- internal/adapter: Provider Adapter抽象层和OpenAI实现
- internal/router: 多Provider路由(支持latency/weighted/availability策略)
- internal/handler: OpenAI兼容API端点(/v1/chat/completions, /v1/completions)
- internal/ratelimit: Token Bucket和Sliding Window限流器
- internal/alert: 告警系统(支持邮件/钉钉/飞书)
- internal/config: 配置管理
- pkg/error: 完整错误码体系
- pkg/model: API请求/响应模型

PRD对齐:
- P0-1: 统一API接入  (OpenAI兼容)
- P0-2: 基础路由与稳定性  (多Provider路由+Fallback)
- P0-4: 预算与限流  (Token Bucket限流)

注意:需要供应链模块支持后再完善成本归因和账单导出
2026-04-01 10:04:52 +08:00

367 lines
8.5 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 alert
import (
"bytes"
"context"
"crypto/hmac"
"crypto/sha256"
"encoding/base64"
"encoding/json"
"fmt"
"net/http"
"net/smtp"
"strings"
"time"
"lijiaoqiao/gateway/internal/config"
)
// AlertType 告警类型
type AlertType string
const (
AlertBudgetExceeded AlertType = "budget_exceeded"
AlertRateLimitExceeded AlertType = "rate_limit_exceeded"
AlertProviderFailure AlertType = "provider_failure"
AlertHighErrorRate AlertType = "high_error_rate"
AlertLatencySpike AlertType = "latency_spike"
AlertManualIntervention AlertType = "manual_intervention"
)
// Alert 告警
type Alert struct {
Type AlertType
Title string
Message string
Severity string // "info", "warning", "error", "critical"
TenantID int64
RequestID string
Metadata map[string]interface{}
Timestamp time.Time
}
// Sender 告警发送器接口
type Sender interface {
Send(ctx context.Context, alert *Alert) error
}
// Manager 告警管理器
type Manager struct {
senders []Sender
}
// NewManager 创建告警管理器
func NewManager(cfg *config.AlertConfig) (*Manager, error) {
m := &Manager{
senders: make([]Sender, 0),
}
// 添加邮件发送器
if cfg.Email.Enabled {
m.senders = append(m.senders, NewEmailSender(&cfg.Email))
}
// 添加钉钉发送器
if cfg.DingTalk.Enabled {
sender, err := NewDingTalkSender(cfg.DingTalk.WebHook, cfg.DingTalk.Secret)
if err != nil {
return nil, fmt.Errorf("failed to create DingTalk sender: %w", err)
}
m.senders = append(m.senders, sender)
}
// 添加飞书发送器
if cfg.Feishu.Enabled {
sender, err := NewFeishuSender(cfg.Feishu.WebHook, cfg.Feishu.Secret)
if err != nil {
return nil, fmt.Errorf("failed to create Feishu sender: %w", err)
}
m.senders = append(m.senders, sender)
}
return m, nil
}
// Send 发送告警
func (m *Manager) Send(ctx context.Context, alert *Alert) error {
if len(m.senders) == 0 {
return fmt.Errorf("no alert sender configured")
}
var lastErr error
for _, sender := range m.senders {
if err := sender.Send(ctx, alert); err != nil {
lastErr = err
// 继续尝试其他发送器
continue
}
}
return lastErr
}
// SendBudgetAlert 发送预算告警
func (m *Manager) SendBudgetAlert(ctx context.Context, tenantID int64, current, limit float64) error {
return m.Send(ctx, &Alert{
Type: AlertBudgetExceeded,
Title: "Budget Alert",
Message: fmt.Sprintf("Tenant %d exceeded budget: current=%.2f, limit=%.2f", tenantID, current, limit),
Severity: "warning",
TenantID: tenantID,
Metadata: map[string]interface{}{
"current_usage": current,
"limit": limit,
},
Timestamp: time.Now(),
})
}
// SendProviderFailureAlert 发送Provider故障告警
func (m *Manager) SendProviderFailureAlert(ctx context.Context, provider string, err error) error {
return m.Send(ctx, &Alert{
Type: AlertProviderFailure,
Title: "Provider Failure",
Message: fmt.Sprintf("Provider %s failed: %v", provider, err),
Severity: "error",
Metadata: map[string]interface{}{
"provider": provider,
"error": err.Error(),
},
Timestamp: time.Now(),
})
}
// EmailSender 邮件发送器
type EmailSender struct {
cfg *config.EmailConfig
}
// NewEmailSender 创建邮件发送器
func NewEmailSender(cfg *config.EmailConfig) *EmailSender {
return &EmailSender{cfg: cfg}
}
func (s *EmailSender) Send(ctx context.Context, alert *Alert) error {
// 构建邮件内容
subject := fmt.Sprintf("[%s] %s - %s", strings.ToUpper(alert.Severity), alert.Type, alert.Title)
body := fmt.Sprintf(`
告警类型: %s
严重程度: %s
时间: %s
消息: %s
`, alert.Type, alert.Severity, alert.Timestamp.Format(time.RFC3339), alert.Message)
if alert.TenantID > 0 {
body += fmt.Sprintf("\n租户ID: %d", alert.TenantID)
}
// 构建邮件
msg := fmt.Sprintf("From: %s\r\n"+
"To: %s\r\n"+
"Subject: %s\r\n"+
"Content-Type: text/plain; charset=UTF-8\r\n"+
"\r\n"+
"%s",
s.cfg.From,
strings.Join(s.cfg.To, ","),
subject,
body)
// 发送邮件
addr := fmt.Sprintf("%s:%d", s.cfg.Host, s.cfg.Port)
auth := smtp.PlainAuth("", s.cfg.Username, s.cfg.Password, s.cfg.Host)
err := smtp.SendMail(addr, auth, s.cfg.From, s.cfg.To, []byte(msg))
if err != nil {
return fmt.Errorf("failed to send email: %w", err)
}
return nil
}
// DingTalkSender 钉钉发送器
type DingTalkSender struct {
webHook string
secret string
client *http.Client
}
// NewDingTalkSender 创建钉钉发送器
func NewDingTalkSender(webHook, secret string) (*DingTalkSender, error) {
return &DingTalkSender{
webHook: webHook,
secret: secret,
client: &http.Client{
Timeout: 10 * time.Second,
},
}, nil
}
func (s *DingTalkSender) Send(ctx context.Context, alert *Alert) error {
// 获取签名
timestamp, sign := s.generateSign()
// 构建请求URL
url := fmt.Sprintf("%s&timestamp=%d&sign=%s", s.webHook, timestamp, sign)
// 构建消息
msg := map[string]interface{}{
"msgtype": "markdown",
"markdown": map[string]string{
"title": fmt.Sprintf("[%s] %s", strings.ToUpper(alert.Severity), alert.Title),
"text": fmt.Sprintf(`### [%s] %s
**类型**: %s
**严重程度**: %s
**时间**: %s
**消息**: %s`,
strings.ToUpper(alert.Severity),
alert.Title,
alert.Type,
alert.Severity,
alert.Timestamp.Format(time.RFC3339),
alert.Message,
),
},
}
jsonData, _ := json.Marshal(msg)
req, err := http.NewRequestWithContext(ctx, "POST", url, bytes.NewBuffer(jsonData))
if err != nil {
return err
}
req.Header.Set("Content-Type", "application/json")
resp, err := s.client.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("DingTalk API returned status: %d", resp.StatusCode)
}
return nil
}
func (s *DingTalkSender) generateSign() (int64, string) {
timestamp := time.Now().UnixMilli()
stringToSign := fmt.Sprintf("%d\n%s", timestamp, s.secret)
h := hmac.New(sha256.New, []byte(s.secret))
h.Write([]byte(stringToSign))
signature := base64.StdEncoding.EncodeToString(h.Sum(nil))
return timestamp, urlEncode(signature)
}
// FeishuSender 飞书发送器
type FeishuSender struct {
webHook string
secret string
client *http.Client
}
// NewFeishuSender 创建飞书发送器
func NewFeishuSender(webHook, secret string) (*FeishuSender, error) {
return &FeishuSender{
webHook: webHook,
secret: secret,
client: &http.Client{
Timeout: 10 * time.Second,
},
}, nil
}
func (s *FeishuSender) Send(ctx context.Context, alert *Alert) error {
// 获取tenant_access_token (简化实现)
token, err := s.getTenantAccessToken()
if err != nil {
return err
}
// 构建消息
msg := map[string]interface{}{
"msg_type": "interactive",
"card": map[string]interface{}{
"header": map[string]interface{}{
"title": map[string]string{
"tag": "plain_text",
"content": fmt.Sprintf("[%s] %s", strings.ToUpper(alert.Severity), alert.Title),
},
"template": s.getTemplateColor(alert.Severity),
},
"elements": []map[string]interface{}{
{
"tag": "div",
"text": map[string]string{
"tag": "lark_md",
"content": fmt.Sprintf("**类型**: %s\n**严重程度**: %s\n**时间**: %s\n**消息**: %s",
alert.Type,
alert.Severity,
alert.Timestamp.Format(time.RFC3339),
alert.Message,
),
},
},
},
},
}
jsonData, _ := json.Marshal(msg)
url := fmt.Sprintf("%s?tenant_access_token=%s", s.webHook, token)
req, err := http.NewRequestWithContext(ctx, "POST", url, bytes.NewBuffer(jsonData))
if err != nil {
return err
}
req.Header.Set("Content-Type", "application/json")
resp, err := s.client.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("Feishu API returned status: %d", resp.StatusCode)
}
return nil
}
func (s *FeishuSender) getTenantAccessToken() (string, error) {
// 简化实现实际应该调用飞书API获取tenant_access_token
// https://open.feishu.cn/document/ukTMukTMukTM/ukDNz4SO0MjL5QDO/auth-v3/auth/tenant_access_token/internal
return "dummy_token", nil
}
func (s *FeishuSender) getTemplateColor(severity string) string {
switch severity {
case "critical":
return "red"
case "error":
return "orange"
case "warning":
return "yellow"
default:
return "blue"
}
}
// urlEncode URL编码
func urlEncode(str string) string {
result := ""
for _, c := range str {
if c == '+' || c == ' ' || c == '/' || c == '=' {
result += fmt.Sprintf("%%%02X", c)
} else {
result += string(c)
}
}
return result
}