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限流) 注意:需要供应链模块支持后再完善成本归因和账单导出
This commit is contained in:
366
gateway/internal/alert/alert.go
Normal file
366
gateway/internal/alert/alert.go
Normal file
@@ -0,0 +1,366 @@
|
||||
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×tamp=%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
|
||||
}
|
||||
Reference in New Issue
Block a user