Version: 0.9.68.dev.260504
后端: 1. 阶段 3 notification 服务边界落地,新增 `cmd/notification`、`services/notification`、`gateway/notification`、`shared/contracts/notification` 和 notification port,按 userauth 同款最小手搓 zrpc 样板收口 2. notification outbox consumer、relay 和 retry loop 迁入独立服务入口,处理 `notification.feishu.requested`,gateway 改为通过 zrpc client 调用 notification 3. 清退旧单体 notification DAO/model/service/provider/runner 和 `service/events/notification_feishu.go`,旧实现不再作为活跃编译路径 4. 修复 outbox 路由归属、dispatch 启动扫描、Kafka topic 探测/投递超时、sending 租约恢复、毒消息 MarkDead 错误回传和 RPC timeout 边界 5. 同步调整 active-scheduler 触发通知事件、核心 outbox handler、MySQL 迁移边界和 notification 配置 文档: 1. 更新微服务迁移计划,将阶段 3 notification 标记为已完成,并明确下一阶段从 active-scheduler 开始
This commit is contained in:
139
backend/services/notification/internal/feishu/mock.go
Normal file
139
backend/services/notification/internal/feishu/mock.go
Normal file
@@ -0,0 +1,139 @@
|
||||
package feishu
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// MockMode 描述 mock provider 下一次返回哪类结果。
|
||||
type MockMode string
|
||||
|
||||
const (
|
||||
MockModeSuccess MockMode = "success"
|
||||
MockModeTemporaryFail MockMode = "temporary_fail"
|
||||
MockModePermanentFail MockMode = "permanent_fail"
|
||||
)
|
||||
|
||||
// MockProvider 是进程内 mock provider。
|
||||
//
|
||||
// 职责边界:
|
||||
// 1. 只用于本地联调、单元测试和阶段性验收;
|
||||
// 2. 不做真实 HTTP 调用,直接根据预设 mode 返回 success / temporary_fail / permanent_fail;
|
||||
// 3. 保留调用历史,方便测试断言“有没有重复发飞书”。
|
||||
type MockProvider struct {
|
||||
mu sync.Mutex
|
||||
defaultMode MockMode
|
||||
queuedModes []MockMode
|
||||
calls []SendRequest
|
||||
}
|
||||
|
||||
func NewMockProvider(defaultMode MockMode) *MockProvider {
|
||||
if defaultMode == "" {
|
||||
defaultMode = MockModeSuccess
|
||||
}
|
||||
return &MockProvider{defaultMode: defaultMode}
|
||||
}
|
||||
|
||||
func (p *MockProvider) SetDefaultMode(mode MockMode) {
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
if mode == "" {
|
||||
mode = MockModeSuccess
|
||||
}
|
||||
p.defaultMode = mode
|
||||
}
|
||||
|
||||
// PushModes 追加一组“一次性模式”。
|
||||
//
|
||||
// 说明:
|
||||
// 1. 先进先出消费,便于测试“先失败再成功”的重试路径;
|
||||
// 2. 队列用尽后回退到 defaultMode;
|
||||
// 3. 空模式会被自动忽略,避免测试代码误塞脏数据。
|
||||
func (p *MockProvider) PushModes(modes ...MockMode) {
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
for _, mode := range modes {
|
||||
if mode == "" {
|
||||
continue
|
||||
}
|
||||
p.queuedModes = append(p.queuedModes, mode)
|
||||
}
|
||||
}
|
||||
|
||||
func (p *MockProvider) Calls() []SendRequest {
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
copied := make([]SendRequest, len(p.calls))
|
||||
copy(copied, p.calls)
|
||||
return copied
|
||||
}
|
||||
|
||||
// Send 按预设模式返回模拟结果。
|
||||
//
|
||||
// 步骤说明:
|
||||
// 1. 先记录本次请求,方便测试校验是否发生重复投递;
|
||||
// 2. 再按 queuedModes -> defaultMode 的顺序决定 outcome;
|
||||
// 3. 最后返回可落库审计的 request/response 摘要。
|
||||
func (p *MockProvider) Send(_ context.Context, req SendRequest) (SendResult, error) {
|
||||
p.mu.Lock()
|
||||
p.calls = append(p.calls, req)
|
||||
|
||||
mode := p.defaultMode
|
||||
if len(p.queuedModes) > 0 {
|
||||
mode = p.queuedModes[0]
|
||||
p.queuedModes = p.queuedModes[1:]
|
||||
}
|
||||
p.mu.Unlock()
|
||||
|
||||
switch mode {
|
||||
case MockModeTemporaryFail:
|
||||
return SendResult{
|
||||
Outcome: SendOutcomeTemporaryFail,
|
||||
ErrorCode: ErrorCodeProviderTimeout,
|
||||
ErrorMessage: "mock feishu provider temporary failure",
|
||||
RequestPayload: map[string]any{
|
||||
"notification_id": req.NotificationID,
|
||||
"user_id": req.UserID,
|
||||
"preview_id": req.PreviewID,
|
||||
"target_url": req.TargetURL,
|
||||
},
|
||||
ResponsePayload: map[string]any{
|
||||
"mode": string(mode),
|
||||
"reason": "mock temporary failure",
|
||||
},
|
||||
}, nil
|
||||
case MockModePermanentFail:
|
||||
return SendResult{
|
||||
Outcome: SendOutcomePermanentFail,
|
||||
ErrorCode: ErrorCodePayloadInvalid,
|
||||
ErrorMessage: "mock feishu provider permanent failure",
|
||||
RequestPayload: map[string]any{
|
||||
"notification_id": req.NotificationID,
|
||||
"user_id": req.UserID,
|
||||
"preview_id": req.PreviewID,
|
||||
"target_url": req.TargetURL,
|
||||
},
|
||||
ResponsePayload: map[string]any{
|
||||
"mode": string(mode),
|
||||
"reason": "mock permanent failure",
|
||||
},
|
||||
}, nil
|
||||
default:
|
||||
return SendResult{
|
||||
Outcome: SendOutcomeSuccess,
|
||||
ProviderMessageID: fmt.Sprintf("mock_feishu_%d", time.Now().UnixNano()),
|
||||
RequestPayload: map[string]any{
|
||||
"notification_id": req.NotificationID,
|
||||
"user_id": req.UserID,
|
||||
"preview_id": req.PreviewID,
|
||||
"target_url": req.TargetURL,
|
||||
},
|
||||
ResponsePayload: map[string]any{
|
||||
"mode": string(MockModeSuccess),
|
||||
"status": "ok",
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
}
|
||||
88
backend/services/notification/internal/feishu/types.go
Normal file
88
backend/services/notification/internal/feishu/types.go
Normal file
@@ -0,0 +1,88 @@
|
||||
package feishu
|
||||
|
||||
import "context"
|
||||
|
||||
const (
|
||||
// Channel 表示当前通知记录走飞书通道。
|
||||
Channel = "feishu"
|
||||
)
|
||||
|
||||
const (
|
||||
// ErrorCodeProviderTimeout 表示 provider 超时,属于可重试错误。
|
||||
ErrorCodeProviderTimeout = "provider_timeout"
|
||||
// ErrorCodeProviderRateLimited 表示 provider 限流,属于可重试错误。
|
||||
ErrorCodeProviderRateLimited = "provider_rate_limited"
|
||||
// ErrorCodeProvider5xx 表示 provider 服务端异常,属于可重试错误。
|
||||
ErrorCodeProvider5xx = "provider_5xx"
|
||||
// ErrorCodeNetworkError 表示网络层异常,属于可重试错误。
|
||||
ErrorCodeNetworkError = "network_error"
|
||||
// ErrorCodeRecipientMissing 表示缺少接收方,属于不可恢复错误。
|
||||
ErrorCodeRecipientMissing = "recipient_missing"
|
||||
// ErrorCodeInvalidURL 表示目标链接非法,属于不可恢复错误。
|
||||
ErrorCodeInvalidURL = "invalid_url"
|
||||
// ErrorCodeProviderAuthFailed 表示 provider 认证失败,属于不可恢复错误。
|
||||
ErrorCodeProviderAuthFailed = "provider_auth_failed"
|
||||
// ErrorCodePayloadInvalid 表示请求体非法,属于不可恢复错误。
|
||||
ErrorCodePayloadInvalid = "payload_invalid"
|
||||
)
|
||||
|
||||
// SendOutcome 表示 provider 对一次投递尝试的分类结果。
|
||||
//
|
||||
// 职责边界:
|
||||
// 1. 只表达 provider 层对“这次投递”是否成功、是否可重试的判断;
|
||||
// 2. 不直接承载 notification_records 的状态机,状态流转由 service 决定;
|
||||
// 3. 后续新增 Webhook / OpenID provider 时,只需返回同一套枚举。
|
||||
type SendOutcome string
|
||||
|
||||
const (
|
||||
SendOutcomeSuccess SendOutcome = "success"
|
||||
SendOutcomeTemporaryFail SendOutcome = "temporary_fail"
|
||||
SendOutcomePermanentFail SendOutcome = "permanent_fail"
|
||||
SendOutcomeSkipped SendOutcome = "skipped"
|
||||
)
|
||||
|
||||
// SendRequest 是通知服务传给 provider 的稳定输入。
|
||||
//
|
||||
// 职责边界:
|
||||
// 1. 只描述 provider 真正发消息所需的信息;
|
||||
// 2. 不暴露 GORM model,避免 provider 依赖数据库细节;
|
||||
// 3. 同时保留审计字段,方便 mock/webhook provider 记录请求摘要。
|
||||
type SendRequest struct {
|
||||
NotificationID int64 `json:"notification_id"`
|
||||
UserID int `json:"user_id"`
|
||||
TriggerID string `json:"trigger_id"`
|
||||
PreviewID string `json:"preview_id"`
|
||||
TriggerType string `json:"trigger_type"`
|
||||
TargetType string `json:"target_type"`
|
||||
TargetID int `json:"target_id"`
|
||||
TargetURL string `json:"target_url"`
|
||||
MessageText string `json:"message_text"`
|
||||
FallbackUsed bool `json:"fallback_used"`
|
||||
TraceID string `json:"trace_id,omitempty"`
|
||||
AttemptCount int `json:"attempt_count"`
|
||||
}
|
||||
|
||||
// SendResult 是 provider 对外返回的投递结果。
|
||||
//
|
||||
// 职责边界:
|
||||
// 1. outcome 决定 service 应该进入 sent / failed / dead 中哪一条路径;
|
||||
// 2. request/response payload 仅用于落库审计,不要求与任意具体 SDK 强绑定;
|
||||
// 3. error_code 需要尽量稳定,便于后续按错误码做告警和排障。
|
||||
type SendResult struct {
|
||||
Outcome SendOutcome `json:"outcome"`
|
||||
ProviderMessageID string `json:"provider_message_id,omitempty"`
|
||||
ErrorCode string `json:"error_code,omitempty"`
|
||||
ErrorMessage string `json:"error_message,omitempty"`
|
||||
RequestPayload any `json:"request_payload,omitempty"`
|
||||
ResponsePayload any `json:"response_payload,omitempty"`
|
||||
}
|
||||
|
||||
// Provider 是飞书投递能力的抽象边界。
|
||||
//
|
||||
// 职责边界:
|
||||
// 1. 负责把最终文案发给具体 provider;
|
||||
// 2. 不负责 notification_records 的创建、去重、状态机和重试节奏;
|
||||
// 3. 调用方只根据 SendResult.Outcome 推进自己的状态机。
|
||||
type Provider interface {
|
||||
Send(ctx context.Context, req SendRequest) (SendResult, error)
|
||||
}
|
||||
361
backend/services/notification/internal/feishu/webhook.go
Normal file
361
backend/services/notification/internal/feishu/webhook.go
Normal file
@@ -0,0 +1,361 @@
|
||||
package feishu
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
notificationmodel "github.com/LoveLosita/smartflow/backend/services/notification/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
|
||||
)
|
||||
|
||||
// ChannelReader 描述 webhook provider 读取用户通知配置所需的最小能力。
|
||||
//
|
||||
// 职责边界:
|
||||
// 1. 只读取 user_id + channel 对应的配置;
|
||||
// 2. 不负责保存配置和测试结果;
|
||||
// 3. 生产环境由 notification/dao.ChannelDAO 实现,测试可替换为内存 fake。
|
||||
type ChannelReader interface {
|
||||
GetUserNotificationChannel(ctx context.Context, userID int, channel string) (*notificationmodel.UserNotificationChannel, error)
|
||||
}
|
||||
|
||||
type WebhookProviderOptions struct {
|
||||
HTTPClient *http.Client
|
||||
FrontendBaseURL string
|
||||
Timeout time.Duration
|
||||
Now func() time.Time
|
||||
}
|
||||
|
||||
// WebhookProvider 把 SmartFlow 通知事件发送到用户配置的飞书 Webhook 触发器。
|
||||
//
|
||||
// 职责边界:
|
||||
// 1. 只负责读取用户 webhook 配置、拼装极简业务 JSON 并执行 HTTP POST;
|
||||
// 2. 不负责 notification_records 的创建、重试节奏和幂等;
|
||||
// 3. 不实现飞书群自定义机器人 msg_type 协议,私聊/群发由飞书流程自行编排。
|
||||
type WebhookProvider struct {
|
||||
store ChannelReader
|
||||
client *http.Client
|
||||
frontendBaseURL string
|
||||
now func() time.Time
|
||||
}
|
||||
|
||||
type WebhookPayload 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 WebhookMessage `json:"message"`
|
||||
TraceID string `json:"trace_id,omitempty"`
|
||||
SentAt string `json:"sent_at"`
|
||||
}
|
||||
|
||||
type WebhookMessage struct {
|
||||
Title string `json:"title"`
|
||||
Summary string `json:"summary"`
|
||||
ActionText string `json:"action_text"`
|
||||
ActionURL string `json:"action_url"`
|
||||
}
|
||||
|
||||
func NewWebhookProvider(store ChannelReader, opts WebhookProviderOptions) (*WebhookProvider, 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 &WebhookProvider{
|
||||
store: store,
|
||||
client: client,
|
||||
frontendBaseURL: normalizeFrontendBaseURL(opts.FrontendBaseURL),
|
||||
now: now,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// BuildWebhookPayload 生成飞书 Webhook 触发器消费的极简业务 JSON。
|
||||
//
|
||||
// 说明:
|
||||
// 1. 该结构不包含飞书群机器人 msg_type 字段;
|
||||
// 2. message 四个字段是飞书流程拼私聊消息的稳定输入;
|
||||
// 3. 其它字段用于用户流程分支、SmartFlow 排障和审计。
|
||||
func BuildWebhookPayload(req SendRequest, frontendBaseURL string, sentAt time.Time) WebhookPayload {
|
||||
if sentAt.IsZero() {
|
||||
sentAt = time.Now()
|
||||
}
|
||||
summary := strings.TrimSpace(req.MessageText)
|
||||
if summary == "" {
|
||||
summary = "我为你生成了一份日程调整建议,请回到系统确认是否应用。"
|
||||
}
|
||||
return WebhookPayload{
|
||||
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: WebhookMessage{
|
||||
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 *WebhookProvider) Send(ctx context.Context, req SendRequest) (SendResult, error) {
|
||||
if p == nil || p.store == nil || p.client == nil {
|
||||
return SendResult{}, errors.New("webhook feishu provider 未初始化")
|
||||
}
|
||||
config, err := p.store.GetUserNotificationChannel(ctx, req.UserID, notificationmodel.ChannelFeishuWebhook)
|
||||
if err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return skippedResult(req, "用户未配置飞书 Webhook 触发器"), nil
|
||||
}
|
||||
return SendResult{}, err
|
||||
}
|
||||
if config == nil || !config.Enabled || strings.TrimSpace(config.WebhookURL) == "" {
|
||||
return skippedResult(req, "用户未启用飞书 Webhook 触发器"), nil
|
||||
}
|
||||
if err = ValidateWebhookURL(config.WebhookURL); err != nil {
|
||||
return SendResult{
|
||||
Outcome: SendOutcomePermanentFail,
|
||||
ErrorCode: ErrorCodeInvalidURL,
|
||||
ErrorMessage: err.Error(),
|
||||
RequestPayload: map[string]any{
|
||||
"notification_id": req.NotificationID,
|
||||
"user_id": req.UserID,
|
||||
"webhook": MaskWebhookURL(config.WebhookURL),
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
||||
payload := BuildWebhookPayload(req, p.frontendBaseURL, p.now())
|
||||
raw, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
return SendResult{}, err
|
||||
}
|
||||
|
||||
httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, strings.TrimSpace(config.WebhookURL), bytes.NewReader(raw))
|
||||
if err != nil {
|
||||
return permanentWebhookResult(req, payload, nil, ErrorCodeInvalidURL, err.Error()), nil
|
||||
}
|
||||
httpReq.Header.Set("Content-Type", "application/json; charset=utf-8")
|
||||
if strings.EqualFold(strings.TrimSpace(config.AuthType), notificationmodel.AuthTypeBearer) && 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, ErrorCodeNetworkError, readErr.Error()), nil
|
||||
}
|
||||
return classifyWebhookHTTPResult(req, payload, responsePayload, resp.StatusCode, body), nil
|
||||
}
|
||||
|
||||
func classifyWebhookHTTPResult(req SendRequest, payload WebhookPayload, responsePayload map[string]any, statusCode int, body []byte) SendResult {
|
||||
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, ErrorCodePayloadInvalid, firstNonEmpty(parsed.Msg, fmt.Sprintf("飞书 webhook 返回 code=%d", *parsed.Code)))
|
||||
}
|
||||
}
|
||||
return SendResult{
|
||||
Outcome: SendOutcomeSuccess,
|
||||
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, ErrorCodeProviderRateLimited, fmt.Sprintf("飞书 webhook HTTP %d", statusCode))
|
||||
case statusCode >= 500:
|
||||
return temporaryWebhookResult(req, payload, responsePayload, ErrorCodeProvider5xx, fmt.Sprintf("飞书 webhook HTTP %d", statusCode))
|
||||
case statusCode == http.StatusUnauthorized || statusCode == http.StatusForbidden:
|
||||
return permanentWebhookResult(req, payload, responsePayload, ErrorCodeProviderAuthFailed, fmt.Sprintf("飞书 webhook 鉴权失败 HTTP %d", statusCode))
|
||||
default:
|
||||
return permanentWebhookResult(req, payload, responsePayload, ErrorCodePayloadInvalid, fmt.Sprintf("飞书 webhook HTTP %d", statusCode))
|
||||
}
|
||||
}
|
||||
|
||||
func skippedResult(req SendRequest, reason string) SendResult {
|
||||
return SendResult{
|
||||
Outcome: SendOutcomeSkipped,
|
||||
ErrorCode: ErrorCodeRecipientMissing,
|
||||
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(_ SendRequest, payload WebhookPayload, responsePayload any, code string, message string) SendResult {
|
||||
return SendResult{
|
||||
Outcome: SendOutcomeTemporaryFail,
|
||||
ErrorCode: code,
|
||||
ErrorMessage: message,
|
||||
RequestPayload: payload,
|
||||
ResponsePayload: responsePayload,
|
||||
}
|
||||
}
|
||||
|
||||
func permanentWebhookResult(_ SendRequest, payload WebhookPayload, responsePayload any, code string, message string) SendResult {
|
||||
return SendResult{
|
||||
Outcome: SendOutcomePermanentFail,
|
||||
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 ErrorCodeProviderTimeout
|
||||
}
|
||||
return ErrorCodeNetworkError
|
||||
}
|
||||
|
||||
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, "/")
|
||||
}
|
||||
|
||||
// ValidateWebhookURL 校验第一版允许保存的飞书 Webhook 触发器地址。
|
||||
func ValidateWebhookURL(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 ""
|
||||
}
|
||||
Reference in New Issue
Block a user