实现内容: - 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限流) 注意:需要供应链模块支持后再完善成本归因和账单导出
367 lines
8.5 KiB
Go
367 lines
8.5 KiB
Go
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
|
||
}
|