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.后续要做的就是补全从异常发生到给用户推送消息之间的逻辑缺口
This commit is contained in:
222
backend/notification/channel_service.go
Normal file
222
backend/notification/channel_service.go
Normal file
@@ -0,0 +1,222 @@
|
||||
package notification
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/LoveLosita/smartflow/backend/model"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
const (
|
||||
ChannelTestStatusSuccess = "success"
|
||||
ChannelTestStatusFailed = "failed"
|
||||
)
|
||||
|
||||
var ErrInvalidChannelConfig = errors.New("notification channel config invalid")
|
||||
|
||||
type UserNotificationChannelStore interface {
|
||||
UserNotificationChannelReader
|
||||
UpsertUserNotificationChannel(ctx context.Context, channel *model.UserNotificationChannel) error
|
||||
DeleteUserNotificationChannel(ctx context.Context, userID int, channel string) error
|
||||
UpdateUserNotificationChannelTestResult(ctx context.Context, userID int, channel string, status string, testErr string, testedAt time.Time) error
|
||||
}
|
||||
|
||||
type SaveFeishuWebhookRequest struct {
|
||||
Enabled bool
|
||||
WebhookURL string
|
||||
AuthType string
|
||||
BearerToken string
|
||||
}
|
||||
|
||||
type ChannelResponse struct {
|
||||
Channel string `json:"channel"`
|
||||
Enabled bool `json:"enabled"`
|
||||
Configured bool `json:"configured"`
|
||||
WebhookURLMask string `json:"webhook_url_mask,omitempty"`
|
||||
AuthType string `json:"auth_type"`
|
||||
HasBearerToken bool `json:"has_bearer_token"`
|
||||
LastTestStatus string `json:"last_test_status,omitempty"`
|
||||
LastTestError string `json:"last_test_error,omitempty"`
|
||||
LastTestAt *time.Time `json:"last_test_at,omitempty"`
|
||||
}
|
||||
|
||||
type TestResult struct {
|
||||
Channel ChannelResponse `json:"channel"`
|
||||
Status string `json:"status"`
|
||||
Outcome string `json:"outcome"`
|
||||
Message string `json:"message,omitempty"`
|
||||
TraceID string `json:"trace_id,omitempty"`
|
||||
SentAt time.Time `json:"sent_at"`
|
||||
Skipped bool `json:"skipped"`
|
||||
Provider string `json:"provider"`
|
||||
}
|
||||
|
||||
type ChannelServiceOptions struct {
|
||||
Now func() time.Time
|
||||
}
|
||||
|
||||
// ChannelService 管理用户通知通道配置和测试发送。
|
||||
//
|
||||
// 职责边界:
|
||||
// 1. 负责保存、查询、删除当前用户的飞书 webhook 配置;
|
||||
// 2. 负责调用同一套 provider 发送测试事件并回写 last_test_*;
|
||||
// 3. 不参与主动调度 trigger / preview / notification_records 状态机。
|
||||
type ChannelService struct {
|
||||
store UserNotificationChannelStore
|
||||
provider FeishuProvider
|
||||
now func() time.Time
|
||||
}
|
||||
|
||||
func NewChannelService(store UserNotificationChannelStore, provider FeishuProvider, opts ChannelServiceOptions) (*ChannelService, error) {
|
||||
if store == nil {
|
||||
return nil, errors.New("notification channel store is nil")
|
||||
}
|
||||
if provider == nil {
|
||||
return nil, errors.New("feishu provider is nil")
|
||||
}
|
||||
now := opts.Now
|
||||
if now == nil {
|
||||
now = time.Now
|
||||
}
|
||||
return &ChannelService{
|
||||
store: store,
|
||||
provider: provider,
|
||||
now: now,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *ChannelService) GetFeishuWebhook(ctx context.Context, userID int) (ChannelResponse, error) {
|
||||
if userID <= 0 {
|
||||
return ChannelResponse{}, ErrInvalidChannelConfig
|
||||
}
|
||||
row, err := s.store.GetUserNotificationChannel(ctx, userID, model.NotificationChannelFeishuWebhook)
|
||||
if err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return ChannelResponse{
|
||||
Channel: model.NotificationChannelFeishuWebhook,
|
||||
AuthType: model.NotificationAuthTypeNone,
|
||||
Configured: false,
|
||||
}, nil
|
||||
}
|
||||
return ChannelResponse{}, err
|
||||
}
|
||||
return responseFromChannel(row), nil
|
||||
}
|
||||
|
||||
func (s *ChannelService) SaveFeishuWebhook(ctx context.Context, userID int, req SaveFeishuWebhookRequest) (ChannelResponse, error) {
|
||||
if userID <= 0 {
|
||||
return ChannelResponse{}, ErrInvalidChannelConfig
|
||||
}
|
||||
webhookURL := strings.TrimSpace(req.WebhookURL)
|
||||
if err := ValidateFeishuWebhookURL(webhookURL); err != nil {
|
||||
return ChannelResponse{}, ErrInvalidChannelConfig
|
||||
}
|
||||
authType := normalizeAuthType(req.AuthType)
|
||||
bearerToken := strings.TrimSpace(req.BearerToken)
|
||||
if authType == model.NotificationAuthTypeBearer && bearerToken == "" {
|
||||
return ChannelResponse{}, ErrInvalidChannelConfig
|
||||
}
|
||||
row := &model.UserNotificationChannel{
|
||||
UserID: userID,
|
||||
Channel: model.NotificationChannelFeishuWebhook,
|
||||
Enabled: req.Enabled,
|
||||
WebhookURL: webhookURL,
|
||||
AuthType: authType,
|
||||
BearerToken: bearerToken,
|
||||
}
|
||||
if err := s.store.UpsertUserNotificationChannel(ctx, row); err != nil {
|
||||
return ChannelResponse{}, err
|
||||
}
|
||||
return s.GetFeishuWebhook(ctx, userID)
|
||||
}
|
||||
|
||||
func (s *ChannelService) DeleteFeishuWebhook(ctx context.Context, userID int) error {
|
||||
if userID <= 0 {
|
||||
return ErrInvalidChannelConfig
|
||||
}
|
||||
return s.store.DeleteUserNotificationChannel(ctx, userID, model.NotificationChannelFeishuWebhook)
|
||||
}
|
||||
|
||||
func (s *ChannelService) TestFeishuWebhook(ctx context.Context, userID int) (TestResult, error) {
|
||||
if userID <= 0 {
|
||||
return TestResult{}, ErrInvalidChannelConfig
|
||||
}
|
||||
now := s.now()
|
||||
traceID := "trace_feishu_webhook_test"
|
||||
sendResult, sendErr := s.provider.Send(ctx, FeishuSendRequest{
|
||||
NotificationID: 0,
|
||||
UserID: userID,
|
||||
TriggerID: "ast_test_webhook",
|
||||
PreviewID: "asp_test_webhook",
|
||||
TriggerType: "manual_test",
|
||||
TargetType: "notification_channel",
|
||||
TargetID: 0,
|
||||
TargetURL: "/schedule-adjust/asp_test_webhook",
|
||||
MessageText: "这是一条 SmartFlow 飞书 Webhook 测试消息。",
|
||||
TraceID: traceID,
|
||||
AttemptCount: 1,
|
||||
})
|
||||
if sendErr != nil {
|
||||
return TestResult{}, sendErr
|
||||
}
|
||||
|
||||
status := ChannelTestStatusFailed
|
||||
testErr := strings.TrimSpace(sendResult.ErrorMessage)
|
||||
if sendResult.Outcome == FeishuSendOutcomeSuccess {
|
||||
status = ChannelTestStatusSuccess
|
||||
testErr = ""
|
||||
}
|
||||
if sendResult.Outcome == FeishuSendOutcomeSkipped && testErr == "" {
|
||||
testErr = "飞书 webhook 未配置或未启用"
|
||||
}
|
||||
if err := s.store.UpdateUserNotificationChannelTestResult(ctx, userID, model.NotificationChannelFeishuWebhook, status, testErr, now); err != nil {
|
||||
return TestResult{}, err
|
||||
}
|
||||
channel, err := s.GetFeishuWebhook(ctx, userID)
|
||||
if err != nil {
|
||||
return TestResult{}, err
|
||||
}
|
||||
return TestResult{
|
||||
Channel: channel,
|
||||
Status: status,
|
||||
Outcome: string(sendResult.Outcome),
|
||||
Message: testErr,
|
||||
TraceID: traceID,
|
||||
SentAt: now,
|
||||
Skipped: sendResult.Outcome == FeishuSendOutcomeSkipped,
|
||||
Provider: ChannelFeishu,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func responseFromChannel(row *model.UserNotificationChannel) ChannelResponse {
|
||||
if row == nil {
|
||||
return ChannelResponse{
|
||||
Channel: model.NotificationChannelFeishuWebhook,
|
||||
AuthType: model.NotificationAuthTypeNone,
|
||||
Configured: false,
|
||||
}
|
||||
}
|
||||
return ChannelResponse{
|
||||
Channel: row.Channel,
|
||||
Enabled: row.Enabled,
|
||||
Configured: strings.TrimSpace(row.WebhookURL) != "",
|
||||
WebhookURLMask: MaskWebhookURL(row.WebhookURL),
|
||||
AuthType: normalizeAuthType(row.AuthType),
|
||||
HasBearerToken: strings.TrimSpace(row.BearerToken) != "",
|
||||
LastTestStatus: row.LastTestStatus,
|
||||
LastTestError: row.LastTestError,
|
||||
LastTestAt: row.LastTestAt,
|
||||
}
|
||||
}
|
||||
|
||||
func normalizeAuthType(authType string) string {
|
||||
switch strings.ToLower(strings.TrimSpace(authType)) {
|
||||
case model.NotificationAuthTypeBearer:
|
||||
return model.NotificationAuthTypeBearer
|
||||
default:
|
||||
return model.NotificationAuthTypeNone
|
||||
}
|
||||
}
|
||||
32
backend/notification/dedupe.go
Normal file
32
backend/notification/dedupe.go
Normal file
@@ -0,0 +1,32 @@
|
||||
package notification
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
// DefaultFeishuDedupeWindow 是 notification 第一版固定的 30 分钟去重窗口。
|
||||
DefaultFeishuDedupeWindow = 30 * time.Minute
|
||||
)
|
||||
|
||||
// BuildTimeWindowDedupeKey 构造“user_id + trigger_type + time_window”去重键。
|
||||
//
|
||||
// 职责边界:
|
||||
// 1. 供事件发布方在生成 `notification.feishu.requested` payload 时复用;
|
||||
// 2. 只负责把 30 分钟窗口归一成稳定 key,不负责落 notification_records;
|
||||
// 3. unfinished_feedback 若要改用 feedback_id / idempotency_key,可不使用这个 helper。
|
||||
func BuildTimeWindowDedupeKey(userID int, triggerType string, requestedAt time.Time, window time.Duration) string {
|
||||
if window <= 0 {
|
||||
window = DefaultFeishuDedupeWindow
|
||||
}
|
||||
if userID <= 0 || strings.TrimSpace(triggerType) == "" || requestedAt.IsZero() {
|
||||
return ""
|
||||
}
|
||||
|
||||
// 1. 先把请求时间归一到固定窗口起点,保证 30 分钟内多次触发得到同一 key。
|
||||
// 2. requestedAt 为空或非法时直接返回空字符串,让上游显式感知入参不完整。
|
||||
windowStartUnix := requestedAt.Unix() / int64(window.Seconds())
|
||||
return fmt.Sprintf("%d:%s:%d", userID, strings.TrimSpace(triggerType), windowStartUnix)
|
||||
}
|
||||
142
backend/notification/mock_provider.go
Normal file
142
backend/notification/mock_provider.go
Normal file
@@ -0,0 +1,142 @@
|
||||
package notification
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// MockFeishuMode 描述 mock provider 下一次返回哪类结果。
|
||||
type MockFeishuMode string
|
||||
|
||||
const (
|
||||
MockFeishuModeSuccess MockFeishuMode = "success"
|
||||
MockFeishuModeTemporaryFail MockFeishuMode = "temporary_fail"
|
||||
MockFeishuModePermanentFail MockFeishuMode = "permanent_fail"
|
||||
)
|
||||
|
||||
// MockFeishuProvider 是进程内 mock provider。
|
||||
//
|
||||
// 职责边界:
|
||||
// 1. 只用于本地联调、单元测试和阶段性验收;
|
||||
// 2. 不做真实 HTTP 调用,直接根据预设 mode 返回 success / temporary_fail / permanent_fail;
|
||||
// 3. 保留调用历史,方便测试断言“有没有重复发飞书”。
|
||||
type MockFeishuProvider struct {
|
||||
mu sync.Mutex
|
||||
defaultMode MockFeishuMode
|
||||
queuedModes []MockFeishuMode
|
||||
calls []FeishuSendRequest
|
||||
}
|
||||
|
||||
// NewMockFeishuProvider 创建一个进程内 mock provider。
|
||||
func NewMockFeishuProvider(defaultMode MockFeishuMode) *MockFeishuProvider {
|
||||
if defaultMode == "" {
|
||||
defaultMode = MockFeishuModeSuccess
|
||||
}
|
||||
return &MockFeishuProvider{defaultMode: defaultMode}
|
||||
}
|
||||
|
||||
// SetDefaultMode 设置默认返回模式。
|
||||
func (p *MockFeishuProvider) SetDefaultMode(mode MockFeishuMode) {
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
if mode == "" {
|
||||
mode = MockFeishuModeSuccess
|
||||
}
|
||||
p.defaultMode = mode
|
||||
}
|
||||
|
||||
// PushModes 追加一组“一次性模式”。
|
||||
//
|
||||
// 说明:
|
||||
// 1. 先进先出消费,便于测试“先失败再成功”的重试路径;
|
||||
// 2. 队列用尽后回退到 defaultMode;
|
||||
// 3. 空模式会被自动忽略,避免测试代码误塞脏数据。
|
||||
func (p *MockFeishuProvider) PushModes(modes ...MockFeishuMode) {
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
for _, mode := range modes {
|
||||
if mode == "" {
|
||||
continue
|
||||
}
|
||||
p.queuedModes = append(p.queuedModes, mode)
|
||||
}
|
||||
}
|
||||
|
||||
// Calls 返回当前 provider 已记录的调用快照。
|
||||
func (p *MockFeishuProvider) Calls() []FeishuSendRequest {
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
copied := make([]FeishuSendRequest, len(p.calls))
|
||||
copy(copied, p.calls)
|
||||
return copied
|
||||
}
|
||||
|
||||
// Send 按预设模式返回模拟结果。
|
||||
//
|
||||
// 步骤说明:
|
||||
// 1. 先记录本次请求,方便测试校验是否发生重复投递;
|
||||
// 2. 再按 queuedModes -> defaultMode 的顺序决定 outcome;
|
||||
// 3. 最后返回可落库审计的 request/response 摘要。
|
||||
func (p *MockFeishuProvider) Send(_ context.Context, req FeishuSendRequest) (FeishuSendResult, 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 MockFeishuModeTemporaryFail:
|
||||
return FeishuSendResult{
|
||||
Outcome: FeishuSendOutcomeTemporaryFail,
|
||||
ErrorCode: FeishuErrorCodeProviderTimeout,
|
||||
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 MockFeishuModePermanentFail:
|
||||
return FeishuSendResult{
|
||||
Outcome: FeishuSendOutcomePermanentFail,
|
||||
ErrorCode: FeishuErrorCodePayloadInvalid,
|
||||
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 FeishuSendResult{
|
||||
Outcome: FeishuSendOutcomeSuccess,
|
||||
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(MockFeishuModeSuccess),
|
||||
"status": "ok",
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
}
|
||||
88
backend/notification/provider.go
Normal file
88
backend/notification/provider.go
Normal file
@@ -0,0 +1,88 @@
|
||||
package notification
|
||||
|
||||
import "context"
|
||||
|
||||
const (
|
||||
// ChannelFeishu 表示当前通知记录走飞书通道。
|
||||
ChannelFeishu = "feishu"
|
||||
)
|
||||
|
||||
const (
|
||||
// FeishuErrorCodeProviderTimeout 表示 provider 超时,属于可重试错误。
|
||||
FeishuErrorCodeProviderTimeout = "provider_timeout"
|
||||
// FeishuErrorCodeProviderRateLimited 表示 provider 限流,属于可重试错误。
|
||||
FeishuErrorCodeProviderRateLimited = "provider_rate_limited"
|
||||
// FeishuErrorCodeProvider5xx 表示 provider 服务端异常,属于可重试错误。
|
||||
FeishuErrorCodeProvider5xx = "provider_5xx"
|
||||
// FeishuErrorCodeNetworkError 表示网络层异常,属于可重试错误。
|
||||
FeishuErrorCodeNetworkError = "network_error"
|
||||
// FeishuErrorCodeRecipientMissing 表示缺少接收方,属于不可恢复错误。
|
||||
FeishuErrorCodeRecipientMissing = "recipient_missing"
|
||||
// FeishuErrorCodeInvalidURL 表示目标链接非法,属于不可恢复错误。
|
||||
FeishuErrorCodeInvalidURL = "invalid_url"
|
||||
// FeishuErrorCodeProviderAuthFailed 表示 provider 认证失败,属于不可恢复错误。
|
||||
FeishuErrorCodeProviderAuthFailed = "provider_auth_failed"
|
||||
// FeishuErrorCodePayloadInvalid 表示请求体非法,属于不可恢复错误。
|
||||
FeishuErrorCodePayloadInvalid = "payload_invalid"
|
||||
)
|
||||
|
||||
// FeishuSendOutcome 表示 provider 对一次投递尝试的分类结果。
|
||||
//
|
||||
// 职责边界:
|
||||
// 1. 只表达 provider 层对“这次投递”是否成功、是否可重试的判断;
|
||||
// 2. 不直接承载 notification_records 的状态机,状态流转由 NotificationService 决定;
|
||||
// 3. future webhook/open_id provider 只要返回同一套枚举,即可复用现有重试逻辑。
|
||||
type FeishuSendOutcome string
|
||||
|
||||
const (
|
||||
FeishuSendOutcomeSuccess FeishuSendOutcome = "success"
|
||||
FeishuSendOutcomeTemporaryFail FeishuSendOutcome = "temporary_fail"
|
||||
FeishuSendOutcomePermanentFail FeishuSendOutcome = "permanent_fail"
|
||||
FeishuSendOutcomeSkipped FeishuSendOutcome = "skipped"
|
||||
)
|
||||
|
||||
// FeishuSendRequest 是通知服务传给 provider 的稳定输入。
|
||||
//
|
||||
// 职责边界:
|
||||
// 1. 只描述 provider 真正发消息所需的信息;
|
||||
// 2. 不暴露 GORM model,避免 provider 依赖数据库细节;
|
||||
// 3. 同时保留审计字段,方便 mock/webhook provider 记录请求摘要。
|
||||
type FeishuSendRequest 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"`
|
||||
}
|
||||
|
||||
// FeishuSendResult 是 provider 对外返回的投递结果。
|
||||
//
|
||||
// 职责边界:
|
||||
// 1. outcome 决定 NotificationService 应该进入 sent / failed / dead 中哪一条路径;
|
||||
// 2. request/response payload 仅用于落库审计,不要求与任意具体 SDK 强绑定;
|
||||
// 3. error_code 需要尽量稳定,便于后续按错误码做告警和排障。
|
||||
type FeishuSendResult struct {
|
||||
Outcome FeishuSendOutcome `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"`
|
||||
}
|
||||
|
||||
// FeishuProvider 是飞书投递能力的抽象边界。
|
||||
//
|
||||
// 职责边界:
|
||||
// 1. 负责把最终文案发给具体 provider;
|
||||
// 2. 不负责 notification_records 的创建、去重、状态机和重试节奏;
|
||||
// 3. 后续新增 WebhookFeishuProvider / OpenIDFeishuProvider 时,只需实现这个接口。
|
||||
type FeishuProvider interface {
|
||||
Send(ctx context.Context, req FeishuSendRequest) (FeishuSendResult, error)
|
||||
}
|
||||
44
backend/notification/runner.go
Normal file
44
backend/notification/runner.go
Normal file
@@ -0,0 +1,44 @@
|
||||
package notification
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log"
|
||||
"time"
|
||||
)
|
||||
|
||||
// StartRetryLoop 启动 notification_records 重试扫描器。
|
||||
//
|
||||
// 说明:
|
||||
// 1. 只在 worker/all 模式启动,api 模式不启动;
|
||||
// 2. provider 失败后的重试由本循环负责,避免通用 outbox 被外部服务慢失败拖住;
|
||||
// 3. 每轮失败只写日志,下一轮继续扫描。
|
||||
func (s *NotificationService) StartRetryLoop(ctx context.Context, every time.Duration, limit int) {
|
||||
if s == nil {
|
||||
return
|
||||
}
|
||||
if every <= 0 {
|
||||
every = time.Minute
|
||||
}
|
||||
if limit <= 0 {
|
||||
limit = 50
|
||||
}
|
||||
go func() {
|
||||
ticker := time.NewTicker(every)
|
||||
defer ticker.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-ticker.C:
|
||||
result, err := s.RetryFeishuNotifications(ctx, time.Now(), limit)
|
||||
if err != nil {
|
||||
log.Printf("飞书通知重试扫描失败: err=%v", err)
|
||||
continue
|
||||
}
|
||||
if result.Scanned > 0 {
|
||||
log.Printf("飞书通知重试扫描完成: scanned=%d sent=%d failed=%d dead=%d skipped=%d", result.Scanned, result.Sent, result.Failed, result.Dead, result.Skipped)
|
||||
}
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
664
backend/notification/service.go
Normal file
664
backend/notification/service.go
Normal file
@@ -0,0 +1,664 @@
|
||||
package notification
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
sharedevents "github.com/LoveLosita/smartflow/backend/shared/events"
|
||||
|
||||
"github.com/LoveLosita/smartflow/backend/model"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
const (
|
||||
defaultMaxAttempts = 5
|
||||
defaultRetryBaseDelay = 5 * time.Minute
|
||||
defaultRetryMaxDelay = 30 * time.Minute
|
||||
defaultSummaryMaxRunes = 180
|
||||
defaultRetryScanBatch = 100
|
||||
defaultFallbackTemplate = "我为你生成了一份日程调整建议,请回到系统确认是否应用。"
|
||||
)
|
||||
|
||||
// NotificationRecordStore 抽象出 notification 模块真正依赖的持久化能力。
|
||||
//
|
||||
// 职责边界:
|
||||
// 1. 只描述 notification_records 读写所需的最小接口;
|
||||
// 2. 允许生产环境直接复用 ActiveScheduleDAO,也允许测试时替换成内存 fake;
|
||||
// 3. 不把 provider、事件总线和业务状态机耦合进存储接口。
|
||||
type NotificationRecordStore interface {
|
||||
CreateNotificationRecord(ctx context.Context, record *model.NotificationRecord) error
|
||||
UpdateNotificationRecordFields(ctx context.Context, notificationID int64, updates map[string]any) error
|
||||
GetNotificationRecordByID(ctx context.Context, notificationID int64) (*model.NotificationRecord, error)
|
||||
FindNotificationRecordByDedupeKey(ctx context.Context, channel string, dedupeKey string) (*model.NotificationRecord, error)
|
||||
ListRetryableNotificationRecords(ctx context.Context, now time.Time, limit int) ([]model.NotificationRecord, error)
|
||||
}
|
||||
|
||||
// ServiceOptions 定义通知服务的可调参数。
|
||||
type ServiceOptions struct {
|
||||
Now func() time.Time
|
||||
MaxAttempts int
|
||||
RetryBaseDelay time.Duration
|
||||
RetryMaxDelay time.Duration
|
||||
SummaryMaxRunes int
|
||||
RetryScanBatch int
|
||||
}
|
||||
|
||||
// HandleResult 描述一次事件处理或一次 retry 尝试的结果。
|
||||
type HandleResult struct {
|
||||
RecordID int64
|
||||
Status string
|
||||
Reused bool
|
||||
Delivered bool
|
||||
FallbackUsed bool
|
||||
AttemptCount int
|
||||
NextRetryAt *time.Time
|
||||
ProviderError string
|
||||
}
|
||||
|
||||
// RetryResult 汇总一次批量 retry 扫描的结果。
|
||||
type RetryResult struct {
|
||||
Scanned int
|
||||
Retried int
|
||||
Sent int
|
||||
Failed int
|
||||
Dead int
|
||||
Skipped int
|
||||
Errors int
|
||||
}
|
||||
|
||||
// Service 负责 notification_records 状态机与 provider 调用编排。
|
||||
//
|
||||
// 职责边界:
|
||||
// 1. 消费 `notification.feishu.requested` payload,做去重、落库、状态流转与 provider 调用;
|
||||
// 2. 只写 notification_records,不写 preview / trigger / 正式 schedule;
|
||||
// 3. provider 可重试失败由本服务自己管理,outbox 只保证“通知请求被接收一次”。
|
||||
type Service struct {
|
||||
store NotificationRecordStore
|
||||
provider FeishuProvider
|
||||
options ServiceOptions
|
||||
locks *keyedLocker
|
||||
}
|
||||
|
||||
// NotificationService 是阶段四对外暴露的语义化别名。
|
||||
//
|
||||
// 说明:
|
||||
// 1. 当前包里已有 runner 等代码引用 `Service`;
|
||||
// 2. 任务描述里又直接使用 “NotificationService” 这个业务名词;
|
||||
// 3. 这里保留别名,既不打断已有代码,也让后续调用方可以按业务语义引用。
|
||||
type NotificationService = Service
|
||||
|
||||
// NewNotificationService 创建通知服务。
|
||||
func NewNotificationService(store NotificationRecordStore, provider FeishuProvider, opts ServiceOptions) (*Service, error) {
|
||||
if store == nil {
|
||||
return nil, errors.New("notification record store is nil")
|
||||
}
|
||||
if provider == nil {
|
||||
return nil, errors.New("feishu provider is nil")
|
||||
}
|
||||
opts = normalizeServiceOptions(opts)
|
||||
return &Service{
|
||||
store: store,
|
||||
provider: provider,
|
||||
options: opts,
|
||||
locks: newKeyedLocker(),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// HandleFeishuRequested 处理一条 `notification.feishu.requested` 事件。
|
||||
//
|
||||
// 步骤说明:
|
||||
// 1. 先校验 shared/events payload,避免脏数据进入状态机;
|
||||
// 2. 再按 `channel + dedupe_key` 串行化处理,保证进程内不会并发重复发同一条飞书;
|
||||
// 3. 若已有 pending/failed,则复用同一条 record 继续投递;sending/sent/dead/skipped 则直接短路。
|
||||
func (s *Service) HandleFeishuRequested(ctx context.Context, payload sharedevents.FeishuNotificationRequestedPayload) (HandleResult, error) {
|
||||
if err := payload.Validate(); err != nil {
|
||||
return HandleResult{}, err
|
||||
}
|
||||
|
||||
lockKey := buildNotificationLockKey(ChannelFeishu, payload.DedupeKey)
|
||||
unlock := s.locks.Lock(lockKey)
|
||||
defer unlock()
|
||||
|
||||
record, reused, err := s.findOrCreateRecordForPayload(ctx, payload)
|
||||
if err != nil {
|
||||
return HandleResult{}, err
|
||||
}
|
||||
|
||||
result, err := s.deliverRecord(ctx, record)
|
||||
if err != nil {
|
||||
return HandleResult{}, err
|
||||
}
|
||||
result.Reused = reused
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// RetryFeishuNotifications 扫描并重试到点的 failed 记录。
|
||||
//
|
||||
// 步骤说明:
|
||||
// 1. 先按 DAO 提供的 retry 查询口径拉取 `status=failed && next_retry_at<=now`;
|
||||
// 2. 再逐条加进程内锁并复用同一条 record 重试,避免 scanner 和事件 handler 打架;
|
||||
// 3. 单条失败不会中断整批扫描,但会在返回值中累计 Errors,并把首个错误回传给调用方。
|
||||
func (s *Service) RetryFeishuNotifications(ctx context.Context, now time.Time, limit int) (RetryResult, error) {
|
||||
if now.IsZero() {
|
||||
now = s.options.Now()
|
||||
}
|
||||
if limit <= 0 {
|
||||
limit = s.options.RetryScanBatch
|
||||
}
|
||||
|
||||
records, err := s.store.ListRetryableNotificationRecords(ctx, now, limit)
|
||||
if err != nil {
|
||||
return RetryResult{}, err
|
||||
}
|
||||
|
||||
result := RetryResult{Scanned: len(records)}
|
||||
var firstErr error
|
||||
|
||||
for _, record := range records {
|
||||
if record.Channel != ChannelFeishu {
|
||||
result.Skipped++
|
||||
continue
|
||||
}
|
||||
|
||||
handleResult, retryErr := s.retryOneRecord(ctx, record.ID)
|
||||
if retryErr != nil {
|
||||
result.Errors++
|
||||
if firstErr == nil {
|
||||
firstErr = retryErr
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
if handleResult.Delivered {
|
||||
result.Retried++
|
||||
}
|
||||
switch handleResult.Status {
|
||||
case model.NotificationRecordStatusSent:
|
||||
if handleResult.Delivered {
|
||||
result.Sent++
|
||||
} else {
|
||||
result.Skipped++
|
||||
}
|
||||
case model.NotificationRecordStatusFailed:
|
||||
result.Failed++
|
||||
case model.NotificationRecordStatusDead:
|
||||
result.Dead++
|
||||
default:
|
||||
result.Skipped++
|
||||
}
|
||||
}
|
||||
|
||||
return result, firstErr
|
||||
}
|
||||
|
||||
func (s *Service) RetryDue(ctx context.Context, now time.Time, limit int) (int, error) {
|
||||
result, err := s.RetryFeishuNotifications(ctx, now, limit)
|
||||
if err != nil {
|
||||
return result.Retried, err
|
||||
}
|
||||
return result.Retried, nil
|
||||
}
|
||||
|
||||
func (s *Service) retryOneRecord(ctx context.Context, notificationID int64) (HandleResult, error) {
|
||||
record, err := s.store.GetNotificationRecordByID(ctx, notificationID)
|
||||
if err != nil {
|
||||
return HandleResult{}, err
|
||||
}
|
||||
|
||||
lockKey := buildNotificationLockKey(record.Channel, record.DedupeKey)
|
||||
unlock := s.locks.Lock(lockKey)
|
||||
defer unlock()
|
||||
|
||||
current, err := s.store.GetNotificationRecordByID(ctx, notificationID)
|
||||
if err != nil {
|
||||
return HandleResult{}, err
|
||||
}
|
||||
return s.deliverRecord(ctx, current)
|
||||
}
|
||||
|
||||
func (s *Service) findOrCreateRecordForPayload(ctx context.Context, payload sharedevents.FeishuNotificationRequestedPayload) (*model.NotificationRecord, bool, error) {
|
||||
// 1. 若 payload 已携带 notification_id,先尝试命中现有记录,便于后续扩展“指定 record 重放”场景。
|
||||
// 2. 若 id 未命中或字段不一致,再退回到 channel + dedupe_key 这一版稳定幂等口径。
|
||||
if payload.NotificationID > 0 {
|
||||
record, err := s.store.GetNotificationRecordByID(ctx, payload.NotificationID)
|
||||
if err == nil && record != nil && record.Channel == ChannelFeishu && record.DedupeKey == strings.TrimSpace(payload.DedupeKey) {
|
||||
return record, true, nil
|
||||
}
|
||||
if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, false, err
|
||||
}
|
||||
}
|
||||
|
||||
record, err := s.store.FindNotificationRecordByDedupeKey(ctx, ChannelFeishu, strings.TrimSpace(payload.DedupeKey))
|
||||
if err == nil {
|
||||
return record, true, nil
|
||||
}
|
||||
if !errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, false, err
|
||||
}
|
||||
|
||||
summaryText, fallbackText, fallbackUsed := s.normalizeMessageTemplate(payload.SummaryText, payload.FallbackText)
|
||||
record = &model.NotificationRecord{
|
||||
Channel: ChannelFeishu,
|
||||
UserID: payload.UserID,
|
||||
TriggerID: strings.TrimSpace(payload.TriggerID),
|
||||
PreviewID: strings.TrimSpace(payload.PreviewID),
|
||||
TriggerType: strings.TrimSpace(payload.TriggerType),
|
||||
TargetType: strings.TrimSpace(payload.TargetType),
|
||||
TargetID: payload.TargetID,
|
||||
DedupeKey: strings.TrimSpace(payload.DedupeKey),
|
||||
TargetURL: strings.TrimSpace(payload.TargetURL),
|
||||
SummaryText: summaryText,
|
||||
FallbackText: fallbackText,
|
||||
FallbackUsed: fallbackUsed,
|
||||
Status: model.NotificationRecordStatusPending,
|
||||
MaxAttempts: s.options.MaxAttempts,
|
||||
TraceID: strings.TrimSpace(payload.TraceID),
|
||||
}
|
||||
|
||||
if err = s.store.CreateNotificationRecord(ctx, record); err != nil {
|
||||
// 1. 并发场景下若唯一索引已被别的协程抢先创建,这里回查 dedupe 记录即可;
|
||||
// 2. 若回查仍失败,说明不是幂等竞争而是真正落库异常,应交给上层重试。
|
||||
existing, findErr := s.store.FindNotificationRecordByDedupeKey(ctx, ChannelFeishu, record.DedupeKey)
|
||||
if findErr == nil {
|
||||
return existing, true, nil
|
||||
}
|
||||
return nil, false, err
|
||||
}
|
||||
return record, false, nil
|
||||
}
|
||||
|
||||
func (s *Service) deliverRecord(ctx context.Context, record *model.NotificationRecord) (HandleResult, error) {
|
||||
if record == nil {
|
||||
return HandleResult{}, errors.New("notification record is nil")
|
||||
}
|
||||
|
||||
switch record.Status {
|
||||
case model.NotificationRecordStatusSending,
|
||||
model.NotificationRecordStatusSent,
|
||||
model.NotificationRecordStatusDead,
|
||||
model.NotificationRecordStatusSkipped:
|
||||
return HandleResult{
|
||||
RecordID: record.ID,
|
||||
Status: record.Status,
|
||||
FallbackUsed: record.FallbackUsed,
|
||||
AttemptCount: record.AttemptCount,
|
||||
NextRetryAt: record.NextRetryAt,
|
||||
}, nil
|
||||
case model.NotificationRecordStatusPending, model.NotificationRecordStatusFailed:
|
||||
// 继续向下走真正投递流程。
|
||||
default:
|
||||
// 1. 未识别状态先保守短路,避免把未知脏数据继续推进到 provider。
|
||||
// 2. 后续若新增新状态,应显式扩展这里的状态机分支。
|
||||
return HandleResult{
|
||||
RecordID: record.ID,
|
||||
Status: record.Status,
|
||||
FallbackUsed: record.FallbackUsed,
|
||||
AttemptCount: record.AttemptCount,
|
||||
NextRetryAt: record.NextRetryAt,
|
||||
}, nil
|
||||
}
|
||||
|
||||
requestPayload := s.buildSendRequest(record)
|
||||
requestJSON, err := marshalJSONPointer(requestPayload)
|
||||
if err != nil {
|
||||
return HandleResult{}, err
|
||||
}
|
||||
|
||||
nextAttemptCount := record.AttemptCount + 1
|
||||
updates := map[string]any{
|
||||
"status": model.NotificationRecordStatusSending,
|
||||
"attempt_count": nextAttemptCount,
|
||||
"next_retry_at": nil,
|
||||
"last_error_code": nil,
|
||||
"last_error": nil,
|
||||
"provider_request_json": requestJSON,
|
||||
}
|
||||
if record.MaxAttempts <= 0 {
|
||||
updates["max_attempts"] = s.options.MaxAttempts
|
||||
record.MaxAttempts = s.options.MaxAttempts
|
||||
}
|
||||
if err = s.store.UpdateNotificationRecordFields(ctx, record.ID, updates); err != nil {
|
||||
return HandleResult{}, err
|
||||
}
|
||||
|
||||
record.Status = model.NotificationRecordStatusSending
|
||||
record.AttemptCount = nextAttemptCount
|
||||
record.NextRetryAt = nil
|
||||
record.ProviderRequestJSON = requestJSON
|
||||
|
||||
sendResult, sendErr := s.provider.Send(ctx, requestPayload)
|
||||
if sendErr != nil && sendResult.Outcome == "" {
|
||||
sendResult = FeishuSendResult{
|
||||
Outcome: FeishuSendOutcomeTemporaryFail,
|
||||
ErrorCode: FeishuErrorCodeNetworkError,
|
||||
ErrorMessage: sendErr.Error(),
|
||||
}
|
||||
}
|
||||
if sendResult.Outcome == "" {
|
||||
sendResult.Outcome = FeishuSendOutcomeTemporaryFail
|
||||
if sendResult.ErrorCode == "" {
|
||||
sendResult.ErrorCode = FeishuErrorCodeNetworkError
|
||||
}
|
||||
if sendResult.ErrorMessage == "" && sendErr != nil {
|
||||
sendResult.ErrorMessage = sendErr.Error()
|
||||
}
|
||||
}
|
||||
|
||||
return s.applySendResult(ctx, record, sendResult)
|
||||
}
|
||||
|
||||
func (s *Service) applySendResult(ctx context.Context, record *model.NotificationRecord, sendResult FeishuSendResult) (HandleResult, error) {
|
||||
now := s.options.Now()
|
||||
responseJSON, err := marshalJSONPointer(sendResult.ResponsePayload)
|
||||
if err != nil {
|
||||
return HandleResult{}, err
|
||||
}
|
||||
requestJSON, err := marshalJSONPointer(sendResult.RequestPayload)
|
||||
if err != nil {
|
||||
return HandleResult{}, err
|
||||
}
|
||||
if requestJSON == nil {
|
||||
requestJSON = record.ProviderRequestJSON
|
||||
}
|
||||
|
||||
errorCode := stringPtrOrNil(sendResult.ErrorCode)
|
||||
errorMessage := stringPtrOrNil(truncateText(sendResult.ErrorMessage, 2000))
|
||||
providerMessageID := stringPtrOrNil(sendResult.ProviderMessageID)
|
||||
|
||||
switch sendResult.Outcome {
|
||||
case FeishuSendOutcomeSuccess:
|
||||
sentAt := now
|
||||
updates := map[string]any{
|
||||
"status": model.NotificationRecordStatusSent,
|
||||
"provider_message_id": providerMessageID,
|
||||
"provider_request_json": requestJSON,
|
||||
"provider_response_json": responseJSON,
|
||||
"last_error_code": nil,
|
||||
"last_error": nil,
|
||||
"next_retry_at": nil,
|
||||
"sent_at": &sentAt,
|
||||
}
|
||||
if err = s.store.UpdateNotificationRecordFields(ctx, record.ID, updates); err != nil {
|
||||
return HandleResult{}, err
|
||||
}
|
||||
return HandleResult{
|
||||
RecordID: record.ID,
|
||||
Status: model.NotificationRecordStatusSent,
|
||||
Delivered: true,
|
||||
FallbackUsed: record.FallbackUsed,
|
||||
AttemptCount: record.AttemptCount,
|
||||
}, nil
|
||||
case FeishuSendOutcomeSkipped:
|
||||
updates := map[string]any{
|
||||
"status": model.NotificationRecordStatusSkipped,
|
||||
"provider_message_id": providerMessageID,
|
||||
"provider_request_json": requestJSON,
|
||||
"provider_response_json": responseJSON,
|
||||
"last_error_code": errorCode,
|
||||
"last_error": errorMessage,
|
||||
"next_retry_at": nil,
|
||||
}
|
||||
if err = s.store.UpdateNotificationRecordFields(ctx, record.ID, updates); err != nil {
|
||||
return HandleResult{}, err
|
||||
}
|
||||
return HandleResult{
|
||||
RecordID: record.ID,
|
||||
Status: model.NotificationRecordStatusSkipped,
|
||||
Delivered: true,
|
||||
FallbackUsed: record.FallbackUsed,
|
||||
AttemptCount: record.AttemptCount,
|
||||
ProviderError: strings.TrimSpace(sendResult.ErrorCode),
|
||||
}, nil
|
||||
case FeishuSendOutcomePermanentFail:
|
||||
updates := map[string]any{
|
||||
"status": model.NotificationRecordStatusDead,
|
||||
"provider_message_id": providerMessageID,
|
||||
"provider_request_json": requestJSON,
|
||||
"provider_response_json": responseJSON,
|
||||
"last_error_code": errorCode,
|
||||
"last_error": errorMessage,
|
||||
"next_retry_at": nil,
|
||||
}
|
||||
if err = s.store.UpdateNotificationRecordFields(ctx, record.ID, updates); err != nil {
|
||||
return HandleResult{}, err
|
||||
}
|
||||
return HandleResult{
|
||||
RecordID: record.ID,
|
||||
Status: model.NotificationRecordStatusDead,
|
||||
Delivered: true,
|
||||
FallbackUsed: record.FallbackUsed,
|
||||
AttemptCount: record.AttemptCount,
|
||||
ProviderError: strings.TrimSpace(sendResult.ErrorCode),
|
||||
}, nil
|
||||
default:
|
||||
if record.AttemptCount >= s.effectiveMaxAttempts(record) {
|
||||
updates := map[string]any{
|
||||
"status": model.NotificationRecordStatusDead,
|
||||
"provider_message_id": providerMessageID,
|
||||
"provider_request_json": requestJSON,
|
||||
"provider_response_json": responseJSON,
|
||||
"last_error_code": errorCode,
|
||||
"last_error": errorMessage,
|
||||
"next_retry_at": nil,
|
||||
}
|
||||
if err = s.store.UpdateNotificationRecordFields(ctx, record.ID, updates); err != nil {
|
||||
return HandleResult{}, err
|
||||
}
|
||||
return HandleResult{
|
||||
RecordID: record.ID,
|
||||
Status: model.NotificationRecordStatusDead,
|
||||
Delivered: true,
|
||||
FallbackUsed: record.FallbackUsed,
|
||||
AttemptCount: record.AttemptCount,
|
||||
ProviderError: strings.TrimSpace(sendResult.ErrorCode),
|
||||
}, nil
|
||||
}
|
||||
|
||||
nextRetryAt := s.calcNextRetryAt(now, record.AttemptCount)
|
||||
updates := map[string]any{
|
||||
"status": model.NotificationRecordStatusFailed,
|
||||
"provider_message_id": providerMessageID,
|
||||
"provider_request_json": requestJSON,
|
||||
"provider_response_json": responseJSON,
|
||||
"last_error_code": errorCode,
|
||||
"last_error": errorMessage,
|
||||
"next_retry_at": &nextRetryAt,
|
||||
}
|
||||
if err = s.store.UpdateNotificationRecordFields(ctx, record.ID, updates); err != nil {
|
||||
return HandleResult{}, err
|
||||
}
|
||||
return HandleResult{
|
||||
RecordID: record.ID,
|
||||
Status: model.NotificationRecordStatusFailed,
|
||||
Delivered: true,
|
||||
FallbackUsed: record.FallbackUsed,
|
||||
AttemptCount: record.AttemptCount,
|
||||
NextRetryAt: &nextRetryAt,
|
||||
ProviderError: strings.TrimSpace(sendResult.ErrorCode),
|
||||
}, nil
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Service) buildSendRequest(record *model.NotificationRecord) FeishuSendRequest {
|
||||
messageText := strings.TrimSpace(record.SummaryText)
|
||||
if record.FallbackUsed || messageText == "" {
|
||||
messageText = strings.TrimSpace(record.FallbackText)
|
||||
}
|
||||
if messageText == "" {
|
||||
messageText = defaultFallbackTemplate
|
||||
}
|
||||
if !strings.Contains(messageText, strings.TrimSpace(record.TargetURL)) {
|
||||
messageText = strings.TrimSpace(messageText) + "\n" + strings.TrimSpace(record.TargetURL)
|
||||
}
|
||||
|
||||
return FeishuSendRequest{
|
||||
NotificationID: record.ID,
|
||||
UserID: record.UserID,
|
||||
TriggerID: record.TriggerID,
|
||||
PreviewID: record.PreviewID,
|
||||
TriggerType: record.TriggerType,
|
||||
TargetType: record.TargetType,
|
||||
TargetID: record.TargetID,
|
||||
TargetURL: record.TargetURL,
|
||||
MessageText: strings.TrimSpace(messageText),
|
||||
FallbackUsed: record.FallbackUsed,
|
||||
TraceID: record.TraceID,
|
||||
AttemptCount: record.AttemptCount + 1,
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Service) normalizeMessageTemplate(summaryText, fallbackText string) (string, string, bool) {
|
||||
normalizedFallback := strings.TrimSpace(fallbackText)
|
||||
if normalizedFallback == "" {
|
||||
normalizedFallback = defaultFallbackTemplate
|
||||
}
|
||||
|
||||
normalizedSummary := strings.TrimSpace(summaryText)
|
||||
if normalizedSummary == "" {
|
||||
return "", normalizedFallback, true
|
||||
}
|
||||
if containsExternalLink(normalizedSummary) {
|
||||
return "", normalizedFallback, true
|
||||
}
|
||||
|
||||
runes := []rune(normalizedSummary)
|
||||
if len(runes) > s.options.SummaryMaxRunes {
|
||||
normalizedSummary = string(runes[:s.options.SummaryMaxRunes])
|
||||
}
|
||||
return strings.TrimSpace(normalizedSummary), normalizedFallback, false
|
||||
}
|
||||
|
||||
func (s *Service) calcNextRetryAt(now time.Time, attemptCount int) time.Time {
|
||||
if attemptCount <= 0 {
|
||||
attemptCount = 1
|
||||
}
|
||||
|
||||
delay := s.options.RetryBaseDelay
|
||||
for idx := 1; idx < attemptCount; idx++ {
|
||||
delay *= 2
|
||||
if delay >= s.options.RetryMaxDelay {
|
||||
delay = s.options.RetryMaxDelay
|
||||
break
|
||||
}
|
||||
}
|
||||
if delay > s.options.RetryMaxDelay {
|
||||
delay = s.options.RetryMaxDelay
|
||||
}
|
||||
return now.Add(delay)
|
||||
}
|
||||
|
||||
func (s *Service) effectiveMaxAttempts(record *model.NotificationRecord) int {
|
||||
if record != nil && record.MaxAttempts > 0 {
|
||||
return record.MaxAttempts
|
||||
}
|
||||
return s.options.MaxAttempts
|
||||
}
|
||||
|
||||
func normalizeServiceOptions(opts ServiceOptions) ServiceOptions {
|
||||
if opts.Now == nil {
|
||||
opts.Now = time.Now
|
||||
}
|
||||
if opts.MaxAttempts <= 0 {
|
||||
opts.MaxAttempts = defaultMaxAttempts
|
||||
}
|
||||
if opts.RetryBaseDelay <= 0 {
|
||||
opts.RetryBaseDelay = defaultRetryBaseDelay
|
||||
}
|
||||
if opts.RetryMaxDelay <= 0 {
|
||||
opts.RetryMaxDelay = defaultRetryMaxDelay
|
||||
}
|
||||
if opts.RetryMaxDelay < opts.RetryBaseDelay {
|
||||
opts.RetryMaxDelay = opts.RetryBaseDelay
|
||||
}
|
||||
if opts.SummaryMaxRunes <= 0 {
|
||||
opts.SummaryMaxRunes = defaultSummaryMaxRunes
|
||||
}
|
||||
if opts.RetryScanBatch <= 0 {
|
||||
opts.RetryScanBatch = defaultRetryScanBatch
|
||||
}
|
||||
return opts
|
||||
}
|
||||
|
||||
func buildNotificationLockKey(channel, dedupeKey string) string {
|
||||
return strings.TrimSpace(channel) + "|" + strings.TrimSpace(dedupeKey)
|
||||
}
|
||||
|
||||
func marshalJSONPointer(value any) (*string, error) {
|
||||
if value == nil {
|
||||
return nil, nil
|
||||
}
|
||||
raw, err := json.Marshal(value)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
text := string(raw)
|
||||
return &text, nil
|
||||
}
|
||||
|
||||
func stringPtrOrNil(value string) *string {
|
||||
trimmed := strings.TrimSpace(value)
|
||||
if trimmed == "" {
|
||||
return nil
|
||||
}
|
||||
return &trimmed
|
||||
}
|
||||
|
||||
func truncateText(value string, limit int) string {
|
||||
if limit <= 0 {
|
||||
return ""
|
||||
}
|
||||
runes := []rune(strings.TrimSpace(value))
|
||||
if len(runes) <= limit {
|
||||
return string(runes)
|
||||
}
|
||||
return string(runes[:limit])
|
||||
}
|
||||
|
||||
func containsExternalLink(text string) bool {
|
||||
lowered := strings.ToLower(strings.TrimSpace(text))
|
||||
return strings.Contains(lowered, "://") || strings.Contains(lowered, "www.")
|
||||
}
|
||||
|
||||
type keyedLocker struct {
|
||||
mu sync.Mutex
|
||||
locks map[string]*keyedLockEntry
|
||||
}
|
||||
|
||||
type keyedLockEntry struct {
|
||||
mu sync.Mutex
|
||||
refs int
|
||||
}
|
||||
|
||||
func newKeyedLocker() *keyedLocker {
|
||||
return &keyedLocker{
|
||||
locks: make(map[string]*keyedLockEntry),
|
||||
}
|
||||
}
|
||||
|
||||
func (l *keyedLocker) Lock(key string) func() {
|
||||
l.mu.Lock()
|
||||
entry := l.locks[key]
|
||||
if entry == nil {
|
||||
entry = &keyedLockEntry{}
|
||||
l.locks[key] = entry
|
||||
}
|
||||
entry.refs++
|
||||
l.mu.Unlock()
|
||||
|
||||
entry.mu.Lock()
|
||||
|
||||
return func() {
|
||||
entry.mu.Unlock()
|
||||
l.mu.Lock()
|
||||
entry.refs--
|
||||
if entry.refs == 0 {
|
||||
delete(l.locks, key)
|
||||
}
|
||||
l.mu.Unlock()
|
||||
}
|
||||
}
|
||||
361
backend/notification/webhook_provider.go
Normal file
361
backend/notification/webhook_provider.go
Normal file
@@ -0,0 +1,361 @@
|
||||
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 ""
|
||||
}
|
||||
Reference in New Issue
Block a user