Files
smartmate/backend/notification/channel_service.go
Losita a3eaa9b2c2 Version: 0.9.61.dev.260501
后端:
1. 主动调度 graph + session bridge 收口——把 dry-run / select / preview / confirm / rerun 串成受限 graph,新增 active_schedule_sessions 缓存与聊天拦截,ready_preview 后释放回自由聊天
2. 会话与通知链路对齐——notification 统一绑定 conversation_id,action_url 指向 /assistant/{conversation_id},会话不存在改回 404 语义,避免 wrong param type 误导排障
3. estimated_sections 写入与主动调度消费链路补齐——任务创建、quick task 与随口记入口都透传估计节数,主动调度只消费落库值

前端:
4. AssistantPanel 最小适配主动调度预览与失败态——复用主动调度卡片/微调弹窗,补历史加载失败可见提示与跨账号会话拦截

文档:
5. 更新主动调度缺口分阶段实施计划和实现方案,标记阶段 0-2 收口并同步接力状态
2026-05-01 20:48:32 +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: "/assistant/00000000-0000-0000-0000-000000000000",
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
}
}