后端: 1.接入主动调度 worker 与飞书通知链路 - 新增 due job scanner 与 active_schedule.triggered workflow - 接入 notification.feishu.requested handler、飞书 webhook provider 和用户通知配置接口 - 支持 notification_records 去重、重试、skipped/dead 状态流转 - 完成 api / worker / all 启动模式装配与主动调度验收记录 2.后续要做的就是补全从异常发生到给用户推送消息之间的逻辑缺口
223 lines
6.9 KiB
Go
223 lines
6.9 KiB
Go
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
|
||
}
|
||
}
|