Files
smartmate/backend/notification/channel_service.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

223 lines
6.9 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 (
"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
}
}