Files
smartmate/backend/notification/webhook_provider.go
Losita 0a014f7472 Version: 0.9.60.dev.260430
后端:
1.接入主动调度 worker 与飞书通知链路
- 新增 due job scanner 与 active_schedule.triggered workflow
- 接入 notification.feishu.requested handler、飞书 webhook provider 和用户通知配置接口
- 支持 notification_records 去重、重试、skipped/dead 状态流转
- 完成 api / worker / all 启动模式装配与主动调度验收记录
2.后续要做的就是补全从异常发生到给用户推送消息之间的逻辑缺口
2026-04-30 23:45:27 +08:00

362 lines
12 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 notification
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"strings"
"time"
"github.com/LoveLosita/smartflow/backend/model"
"gorm.io/gorm"
)
const (
defaultWebhookTimeout = 5 * time.Second
defaultFrontendBaseURL = "https://smartflow.example.com"
webhookPayloadEvent = "smartflow.schedule_adjustment_ready"
webhookPayloadVersion = "1"
webhookMessageTitle = "SmartFlow 日程调整建议"
webhookMessageActionText = "查看并确认调整"
maxWebhookResponseBodyLen = 64 * 1024
)
// UserNotificationChannelReader 描述 webhook provider 读取用户通知配置所需的最小能力。
//
// 职责边界:
// 1. 只读取 user_id + channel 对应的配置;
// 2. 不负责保存配置和测试结果;
// 3. 生产环境由 NotificationChannelDAO 实现,测试可替换为内存 fake。
type UserNotificationChannelReader interface {
GetUserNotificationChannel(ctx context.Context, userID int, channel string) (*model.UserNotificationChannel, error)
}
type WebhookFeishuProviderOptions struct {
HTTPClient *http.Client
FrontendBaseURL string
Timeout time.Duration
Now func() time.Time
}
// WebhookFeishuProvider 把 SmartFlow 通知事件发送到用户配置的飞书 Webhook 触发器。
//
// 职责边界:
// 1. 只负责读取用户 webhook 配置、拼装极简业务 JSON 并执行 HTTP POST
// 2. 不负责 notification_records 的创建、重试节奏和幂等;
// 3. 不实现飞书群自定义机器人 msg_type 协议,私聊/群发由飞书流程自行编排。
type WebhookFeishuProvider struct {
store UserNotificationChannelReader
client *http.Client
frontendBaseURL string
now func() time.Time
}
type FeishuWebhookPayload struct {
Event string `json:"event"`
Version string `json:"version"`
NotificationID int64 `json:"notification_id"`
UserID int `json:"user_id"`
PreviewID string `json:"preview_id"`
TriggerID string `json:"trigger_id"`
TriggerType string `json:"trigger_type"`
TargetType string `json:"target_type"`
TargetID int `json:"target_id"`
Message FeishuWebhookMessage `json:"message"`
TraceID string `json:"trace_id,omitempty"`
SentAt string `json:"sent_at"`
}
type FeishuWebhookMessage struct {
Title string `json:"title"`
Summary string `json:"summary"`
ActionText string `json:"action_text"`
ActionURL string `json:"action_url"`
}
func NewWebhookFeishuProvider(store UserNotificationChannelReader, opts WebhookFeishuProviderOptions) (*WebhookFeishuProvider, error) {
if store == nil {
return nil, errors.New("user notification channel store is nil")
}
timeout := opts.Timeout
if timeout <= 0 {
timeout = defaultWebhookTimeout
}
client := opts.HTTPClient
if client == nil {
client = &http.Client{Timeout: timeout}
}
now := opts.Now
if now == nil {
now = time.Now
}
return &WebhookFeishuProvider{
store: store,
client: client,
frontendBaseURL: normalizeFrontendBaseURL(opts.FrontendBaseURL),
now: now,
}, nil
}
// BuildFeishuWebhookPayload 生成飞书 Webhook 触发器消费的极简业务 JSON。
//
// 说明:
// 1. 该结构不包含飞书群机器人 msg_type 字段;
// 2. message 四个字段是飞书流程拼私聊消息的稳定输入;
// 3. 其它字段用于用户流程分支、SmartFlow 排障和审计。
func BuildFeishuWebhookPayload(req FeishuSendRequest, frontendBaseURL string, sentAt time.Time) FeishuWebhookPayload {
if sentAt.IsZero() {
sentAt = time.Now()
}
summary := strings.TrimSpace(req.MessageText)
if summary == "" {
summary = "我为你生成了一份日程调整建议,请回到系统确认是否应用。"
}
return FeishuWebhookPayload{
Event: webhookPayloadEvent,
Version: webhookPayloadVersion,
NotificationID: req.NotificationID,
UserID: req.UserID,
PreviewID: strings.TrimSpace(req.PreviewID),
TriggerID: strings.TrimSpace(req.TriggerID),
TriggerType: strings.TrimSpace(req.TriggerType),
TargetType: strings.TrimSpace(req.TargetType),
TargetID: req.TargetID,
Message: FeishuWebhookMessage{
Title: webhookMessageTitle,
Summary: summary,
ActionText: webhookMessageActionText,
ActionURL: buildActionURL(frontendBaseURL, req.TargetURL),
},
TraceID: strings.TrimSpace(req.TraceID),
SentAt: sentAt.Format(time.RFC3339),
}
}
// Send 向用户配置的飞书 Webhook 触发器投递一次 SmartFlow 通知事件。
func (p *WebhookFeishuProvider) Send(ctx context.Context, req FeishuSendRequest) (FeishuSendResult, error) {
if p == nil || p.store == nil || p.client == nil {
return FeishuSendResult{}, errors.New("webhook feishu provider 未初始化")
}
config, err := p.store.GetUserNotificationChannel(ctx, req.UserID, model.NotificationChannelFeishuWebhook)
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return skippedResult(req, "用户未配置飞书 Webhook 触发器"), nil
}
return FeishuSendResult{}, err
}
if config == nil || !config.Enabled || strings.TrimSpace(config.WebhookURL) == "" {
return skippedResult(req, "用户未启用飞书 Webhook 触发器"), nil
}
if err = ValidateFeishuWebhookURL(config.WebhookURL); err != nil {
return FeishuSendResult{
Outcome: FeishuSendOutcomePermanentFail,
ErrorCode: FeishuErrorCodeInvalidURL,
ErrorMessage: err.Error(),
RequestPayload: map[string]any{
"notification_id": req.NotificationID,
"user_id": req.UserID,
"webhook": MaskWebhookURL(config.WebhookURL),
},
}, nil
}
payload := BuildFeishuWebhookPayload(req, p.frontendBaseURL, p.now())
raw, err := json.Marshal(payload)
if err != nil {
return FeishuSendResult{}, err
}
httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, strings.TrimSpace(config.WebhookURL), bytes.NewReader(raw))
if err != nil {
return permanentWebhookResult(req, payload, nil, FeishuErrorCodeInvalidURL, err.Error()), nil
}
httpReq.Header.Set("Content-Type", "application/json; charset=utf-8")
if strings.EqualFold(strings.TrimSpace(config.AuthType), model.NotificationAuthTypeBearer) && strings.TrimSpace(config.BearerToken) != "" {
httpReq.Header.Set("Authorization", "Bearer "+strings.TrimSpace(config.BearerToken))
}
resp, err := p.client.Do(httpReq)
if err != nil {
return temporaryWebhookResult(req, payload, nil, classifyNetworkError(err), err.Error()), nil
}
defer resp.Body.Close()
body, readErr := io.ReadAll(io.LimitReader(resp.Body, maxWebhookResponseBodyLen))
responsePayload := buildWebhookResponsePayload(resp.StatusCode, body, readErr)
if readErr != nil {
return temporaryWebhookResult(req, payload, responsePayload, FeishuErrorCodeNetworkError, readErr.Error()), nil
}
return classifyWebhookHTTPResult(req, payload, responsePayload, resp.StatusCode, body), nil
}
func classifyWebhookHTTPResult(req FeishuSendRequest, payload FeishuWebhookPayload, responsePayload map[string]any, statusCode int, body []byte) FeishuSendResult {
if statusCode >= 200 && statusCode < 300 {
if len(strings.TrimSpace(string(body))) > 0 {
var parsed struct {
Code *int `json:"code"`
Msg string `json:"msg"`
}
if err := json.Unmarshal(body, &parsed); err == nil && parsed.Code != nil && *parsed.Code != 0 {
return permanentWebhookResult(req, payload, responsePayload, FeishuErrorCodePayloadInvalid, firstNonEmpty(parsed.Msg, fmt.Sprintf("飞书 webhook 返回 code=%d", *parsed.Code)))
}
}
return FeishuSendResult{
Outcome: FeishuSendOutcomeSuccess,
ProviderMessageID: fmt.Sprintf("feishu_webhook_%d_%d", req.NotificationID, time.Now().UnixNano()),
RequestPayload: payload,
ResponsePayload: responsePayload,
}
}
switch {
case statusCode == http.StatusTooManyRequests:
return temporaryWebhookResult(req, payload, responsePayload, FeishuErrorCodeProviderRateLimited, fmt.Sprintf("飞书 webhook HTTP %d", statusCode))
case statusCode >= 500:
return temporaryWebhookResult(req, payload, responsePayload, FeishuErrorCodeProvider5xx, fmt.Sprintf("飞书 webhook HTTP %d", statusCode))
case statusCode == http.StatusUnauthorized || statusCode == http.StatusForbidden:
return permanentWebhookResult(req, payload, responsePayload, FeishuErrorCodeProviderAuthFailed, fmt.Sprintf("飞书 webhook 鉴权失败 HTTP %d", statusCode))
default:
return permanentWebhookResult(req, payload, responsePayload, FeishuErrorCodePayloadInvalid, fmt.Sprintf("飞书 webhook HTTP %d", statusCode))
}
}
func skippedResult(req FeishuSendRequest, reason string) FeishuSendResult {
return FeishuSendResult{
Outcome: FeishuSendOutcomeSkipped,
ErrorCode: FeishuErrorCodeRecipientMissing,
ErrorMessage: reason,
RequestPayload: map[string]any{
"notification_id": req.NotificationID,
"user_id": req.UserID,
"preview_id": req.PreviewID,
},
ResponsePayload: map[string]any{
"skipped": true,
"reason": reason,
},
}
}
func temporaryWebhookResult(req FeishuSendRequest, payload FeishuWebhookPayload, responsePayload any, code string, message string) FeishuSendResult {
return FeishuSendResult{
Outcome: FeishuSendOutcomeTemporaryFail,
ErrorCode: code,
ErrorMessage: message,
RequestPayload: payload,
ResponsePayload: responsePayload,
}
}
func permanentWebhookResult(req FeishuSendRequest, payload FeishuWebhookPayload, responsePayload any, code string, message string) FeishuSendResult {
return FeishuSendResult{
Outcome: FeishuSendOutcomePermanentFail,
ErrorCode: code,
ErrorMessage: message,
RequestPayload: payload,
ResponsePayload: responsePayload,
}
}
func buildWebhookResponsePayload(statusCode int, body []byte, readErr error) map[string]any {
payload := map[string]any{
"status_code": statusCode,
}
if len(body) > 0 {
payload["body"] = string(body)
}
if readErr != nil {
payload["read_error"] = readErr.Error()
}
return payload
}
func classifyNetworkError(err error) string {
if errors.Is(err, context.DeadlineExceeded) {
return FeishuErrorCodeProviderTimeout
}
return FeishuErrorCodeNetworkError
}
func normalizeFrontendBaseURL(value string) string {
trimmed := strings.TrimRight(strings.TrimSpace(value), "/")
if trimmed == "" {
return defaultFrontendBaseURL
}
return trimmed
}
func buildActionURL(frontendBaseURL string, targetURL string) string {
targetURL = strings.TrimSpace(targetURL)
if strings.HasPrefix(targetURL, "https://") || strings.HasPrefix(targetURL, "http://") {
return targetURL
}
base := normalizeFrontendBaseURL(frontendBaseURL)
return base + "/" + strings.TrimLeft(targetURL, "/")
}
// ValidateFeishuWebhookURL 校验第一版允许保存的飞书 Webhook 触发器地址。
func ValidateFeishuWebhookURL(rawURL string) error {
parsed, err := url.Parse(strings.TrimSpace(rawURL))
if err != nil {
return err
}
if parsed.Scheme != "https" {
return errors.New("飞书 webhook 必须使用 https")
}
host := strings.ToLower(parsed.Hostname())
if host != "www.feishu.cn" && host != "feishu.cn" {
return errors.New("飞书 webhook 域名必须是 feishu.cn")
}
if !strings.HasPrefix(parsed.EscapedPath(), "/flow/api/trigger-webhook/") {
return errors.New("飞书 webhook 路径必须是 /flow/api/trigger-webhook/{key}")
}
return nil
}
// MaskWebhookURL 对 webhook URL 做脱敏,避免接口和日志泄露完整密钥。
func MaskWebhookURL(rawURL string) string {
trimmed := strings.TrimSpace(rawURL)
parsed, err := url.Parse(trimmed)
if err != nil || parsed.Host == "" {
return maskMiddle(trimmed)
}
parts := strings.Split(strings.Trim(parsed.Path, "/"), "/")
if len(parts) == 0 {
return parsed.Scheme + "://" + parsed.Host
}
last := parts[len(parts)-1]
parts[len(parts)-1] = maskMiddle(last)
parsed.Path = "/" + strings.Join(parts, "/")
parsed.RawQuery = ""
parsed.Fragment = ""
return parsed.String()
}
func MaskSecret(value string) string {
return maskMiddle(strings.TrimSpace(value))
}
func maskMiddle(value string) string {
if value == "" {
return ""
}
runes := []rune(value)
if len(runes) <= 8 {
return "****"
}
return string(runes[:4]) + "..." + string(runes[len(runes)-4:])
}
func firstNonEmpty(values ...string) string {
for _, value := range values {
if trimmed := strings.TrimSpace(value); trimmed != "" {
return trimmed
}
}
return ""
}