Files
pig-farm-controller/internal/infra/notify/lark.go

194 lines
5.5 KiB
Go
Raw 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 notify
import (
"bytes"
"encoding/json"
"fmt"
"net/http"
"sync"
"time"
)
const (
// 飞书获取 tenant_access_token 的 API 地址
larkGetTokenURL = "https://open.feishu.cn/open-apis/auth/v3/tenant_access_token/internal"
// 飞书发送消息的 API 地址
larkSendMessageURL = "https://open.feishu.cn/open-apis/im/v1/messages"
)
// larkNotifier 实现了 Notifier 接口,用于通过飞书自建应用发送私聊消息。
type larkNotifier struct {
appID string // 应用 ID
appSecret string // 应用密钥
// 用于线程安全地管理 tenant_access_token
mu sync.Mutex
accessToken string
tokenExpiresAt time.Time
}
// NewLarkNotifier 创建一个新的 larkNotifier 实例。
// 调用者需要注入飞书应用的 AppID 和 AppSecret。
func NewLarkNotifier(appID, appSecret string) Notifier {
return &larkNotifier{
appID: appID,
appSecret: appSecret,
}
}
// Send 向指定用户发送一条飞书消息卡片。
// toAddr 参数是接收者的邮箱地址。
func (l *larkNotifier) Send(content AlarmContent, toAddr string) error {
// 1. 获取有效的 tenant_access_token
token, err := l.getAccessToken()
if err != nil {
return err
}
// 2. 构建消息卡片 JSON
// 飞书消息卡片结构复杂,这里构建一个简单的 Markdown 文本卡片
cardContent := map[string]interface{}{
"config": map[string]bool{
"wide_screen_mode": true,
},
"elements": []map[string]interface{}{
{
"tag": "div",
"text": map[string]string{
"tag": "lark_md",
"content": fmt.Sprintf("## %s\n**级别**: %s\n**时间**: %s\n\n%s",
content.Title,
content.Level.String(),
content.Timestamp.Format(DefaultTimeFormat),
content.Message,
),
},
},
},
}
cardJSON, err := json.Marshal(cardContent)
if err != nil {
return fmt.Errorf("序列化飞书卡片内容失败: %w", err)
}
// 3. 构建请求的 JSON Body
payload := larkMessagePayload{
ReceiveID: toAddr,
ReceiveIDType: "email", // 指定接收者类型为邮箱
MsgType: "interactive", // 消息卡片类型
Content: string(cardJSON),
}
jsonBytes, err := json.Marshal(payload)
if err != nil {
return fmt.Errorf("序列化飞书消息失败: %w", err)
}
// 4. 发送 HTTP POST 请求
url := fmt.Sprintf("%s?receive_id_type=email", larkSendMessageURL) // 在 URL 中指定 receive_id_type
req, err := http.NewRequest("POST", url, bytes.NewBuffer(jsonBytes))
if err != nil {
return fmt.Errorf("创建飞书请求失败: %w", err)
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+token) // 携带 access_token
client := &http.Client{}
resp, err := client.Do(req)
if err != nil {
return fmt.Errorf("发送飞书通知失败: %w", err)
}
defer resp.Body.Close()
// 5. 检查响应
var response larkResponse
if err := json.NewDecoder(resp.Body).Decode(&response); err != nil {
return fmt.Errorf("解析飞书响应失败: %w", err)
}
if response.Code != 0 {
return fmt.Errorf("飞书API返回错误: code=%d, msg=%s", response.Code, response.Msg)
}
return nil
}
// getAccessToken 获取并缓存 tenant_access_token处理了线程安全和自动刷新。
func (l *larkNotifier) getAccessToken() (string, error) {
l.mu.Lock()
defer l.mu.Unlock()
// 如果 token 存在且有效期还有5分钟以上则直接返回缓存的 token
if l.accessToken != "" && time.Now().Before(l.tokenExpiresAt.Add(-5*time.Minute)) {
return l.accessToken, nil
}
// 否则,重新获取 token
payload := map[string]string{
"app_id": l.appID,
"app_secret": l.appSecret,
}
jsonBytes, err := json.Marshal(payload)
if err != nil {
return "", fmt.Errorf("序列化获取 token 请求体失败: %w", err)
}
req, err := http.NewRequest("POST", larkGetTokenURL, bytes.NewBuffer(jsonBytes))
if err != nil {
return "", fmt.Errorf("创建获取 token 请求失败: %w", err)
}
req.Header.Set("Content-Type", "application/json")
client := &http.Client{}
resp, err := client.Do(req)
if err != nil {
return "", fmt.Errorf("获取 tenant_access_token 请求失败: %w", err)
}
defer resp.Body.Close()
var tokenResp larkTokenResponse
if err := json.NewDecoder(resp.Body).Decode(&tokenResp); err != nil {
return "", fmt.Errorf("解析 tenant_access_token 响应失败: %w", err)
}
if tokenResp.Code != 0 {
return "", fmt.Errorf("获取 tenant_access_token API 返回错误: code=%d, msg=%s", tokenResp.Code, tokenResp.Msg)
}
// 缓存新的 token 和过期时间
l.accessToken = tokenResp.TenantAccessToken
l.tokenExpiresAt = time.Now().Add(time.Duration(tokenResp.Expire) * time.Second)
return l.accessToken, nil
}
// Type 返回通知器的类型
func (l *larkNotifier) Type() NotifierType {
return NotifierTypeLark
}
// --- API 数据结构 ---
// larkTokenResponse 是获取 tenant_access_token API 的响应结构体
type larkTokenResponse struct {
Code int `json:"code"`
Msg string `json:"msg"`
TenantAccessToken string `json:"tenant_access_token"`
Expire int `json:"expire"` // 有效期,单位秒
}
// larkMessagePayload 是发送消息 API 的请求体结构
type larkMessagePayload struct {
ReceiveID string `json:"receive_id"`
ReceiveIDType string `json:"receive_id_type"`
MsgType string `json:"msg_type"`
Content string `json:"content"` // 对于 interactive 消息,这里是卡片的 JSON 字符串
}
// larkResponse 是飞书 API 的通用响应结构体
type larkResponse struct {
Code int `json:"code"`
Msg string `json:"msg"`
}