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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user