Files
smartmate/backend/active_scheduler/service/trigger.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

271 lines
9.1 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 service
import (
"context"
"encoding/json"
"errors"
"fmt"
"strings"
"time"
"github.com/LoveLosita/smartflow/backend/active_scheduler/trigger"
"github.com/LoveLosita/smartflow/backend/dao"
outboxinfra "github.com/LoveLosita/smartflow/backend/infra/outbox"
"github.com/LoveLosita/smartflow/backend/model"
sharedevents "github.com/LoveLosita/smartflow/backend/shared/events"
"github.com/google/uuid"
"gorm.io/gorm"
)
const triggerDedupeWindow = 30 * time.Minute
// TriggerRequest 是正式主动调度触发入口的请求 DTO。
//
// 职责边界:
// 1. 负责承载 API trigger、worker due job、用户反馈归一后的触发事实
// 2. 不承载 dry-run 结果、preview 快照或 notification provider 参数;
// 3. Payload 只保存触发来源补充信息,不能塞任意业务写库参数。
type TriggerRequest struct {
UserID int
TriggerType trigger.TriggerType
Source trigger.Source
TargetType trigger.TargetType
TargetID int
FeedbackID string
IdempotencyKey string
DedupeKey string
MockNow *time.Time
IsMockTime bool
RequestedAt time.Time
Payload json.RawMessage
JobID *string
TraceID string
}
// TriggerResponse 是正式触发写入后的结果。
type TriggerResponse struct {
TriggerID string `json:"trigger_id"`
Status string `json:"status"`
PreviewID *string `json:"preview_id,omitempty"`
DedupeHit bool `json:"dedupe_hit"`
TraceID string `json:"trace_id,omitempty"`
}
// TriggerService 负责写入正式 trigger 并发布 active_schedule.triggered 事件。
//
// 职责边界:
// 1. 只负责触发信号持久化、去重和事件发布;
// 2. 不执行 dry-run、不写 preview、不发飞书
// 3. outbox 未启用时返回明确错误,避免调用方误以为正式链路已启动。
type TriggerService struct {
activeDAO *dao.ActiveScheduleDAO
publisher outboxinfra.EventPublisher
clock func() time.Time
}
func NewTriggerService(activeDAO *dao.ActiveScheduleDAO, publisher outboxinfra.EventPublisher) (*TriggerService, error) {
if activeDAO == nil {
return nil, errors.New("active schedule dao 不能为空")
}
return &TriggerService{
activeDAO: activeDAO,
publisher: publisher,
clock: time.Now,
}, nil
}
func (s *TriggerService) SetClock(clock func() time.Time) {
if s != nil && clock != nil {
s.clock = clock
}
}
// CreateAndPublish 创建正式 trigger 并发布 outbox 事件。
//
// 步骤化说明:
// 1. 先按主动调度 trigger DTO 做入口校验,确保 mock_now 不会从 worker 入口混入;
// 2. 再用 idempotency_key / dedupe_key 查询已有 trigger命中则直接返回旧状态
// 3. 新 trigger 先落库,再发布 outbox发布失败会把 trigger 标记 failed便于排障
// 4. 返回 nil error 只表示事件已入 outbox不表示 worker 已经生成 preview。
func (s *TriggerService) CreateAndPublish(ctx context.Context, req TriggerRequest) (*TriggerResponse, error) {
if s == nil || s.activeDAO == nil {
return nil, errors.New("trigger service 未初始化")
}
if s.publisher == nil {
return nil, errors.New("outbox event bus 未启用,无法执行正式主动调度 trigger")
}
now := s.now()
if req.RequestedAt.IsZero() {
req.RequestedAt = now
}
if req.IsMockTime && req.MockNow == nil {
return nil, errors.New("is_mock_time=true 时 mock_now 不能为空")
}
trig := trigger.ActiveScheduleTrigger{
UserID: req.UserID,
TriggerType: req.TriggerType,
Source: req.Source,
TargetType: req.TargetType,
TargetID: req.TargetID,
FeedbackID: req.FeedbackID,
IdempotencyKey: req.IdempotencyKey,
MockNow: req.MockNow,
IsMockTime: req.IsMockTime,
RequestedAt: req.RequestedAt,
TraceID: firstNonEmpty(req.TraceID, fmt.Sprintf("trace_active_trigger_%d", now.UnixNano())),
}
if err := trig.Validate(); err != nil {
return nil, err
}
if trig.Source == trigger.SourceAPIDryRun {
return nil, errors.New("api_dry_run 不允许创建正式 trigger")
}
dedupeKey := strings.TrimSpace(req.DedupeKey)
if dedupeKey == "" {
dedupeKey = BuildTriggerDedupeKey(req.UserID, req.TriggerType, req.TargetType, req.TargetID, req.FeedbackID, req.IdempotencyKey, trig.EffectiveNow(req.RequestedAt))
}
if existing, ok, err := s.findExistingTrigger(ctx, req.UserID, string(req.TriggerType), req.IdempotencyKey, dedupeKey); err != nil {
return nil, err
} else if ok {
return triggerResponseFromModel(existing, true), nil
}
payloadJSON := string(req.Payload)
if strings.TrimSpace(payloadJSON) == "" {
payloadJSON = "{}"
}
triggerID := "ast_" + uuid.NewString()
row := &model.ActiveScheduleTrigger{
ID: triggerID,
UserID: req.UserID,
TriggerType: string(req.TriggerType),
Source: string(req.Source),
TargetType: string(req.TargetType),
TargetID: req.TargetID,
FeedbackID: strings.TrimSpace(req.FeedbackID),
JobID: req.JobID,
IdempotencyKey: strings.TrimSpace(req.IdempotencyKey),
DedupeKey: dedupeKey,
Status: model.ActiveScheduleTriggerStatusPending,
MockNow: req.MockNow,
IsMockTime: req.IsMockTime,
RequestedAt: req.RequestedAt,
PayloadJSON: &payloadJSON,
TraceID: trig.TraceID,
}
if err := s.activeDAO.CreateTrigger(ctx, row); err != nil {
return nil, err
}
eventPayload := sharedevents.ActiveScheduleTriggeredPayload{
TriggerID: row.ID,
UserID: row.UserID,
TriggerType: row.TriggerType,
Source: row.Source,
TargetType: row.TargetType,
TargetID: row.TargetID,
FeedbackID: row.FeedbackID,
IdempotencyKey: row.IdempotencyKey,
DedupeKey: row.DedupeKey,
MockNow: row.MockNow,
IsMockTime: row.IsMockTime,
RequestedAt: row.RequestedAt,
Payload: json.RawMessage(payloadJSON),
TraceID: row.TraceID,
}
if err := eventPayload.Validate(); err != nil {
_ = s.markTriggerFailed(ctx, row.ID, "payload_invalid", err)
return nil, err
}
if err := s.publisher.Publish(ctx, outboxinfra.PublishRequest{
EventType: sharedevents.ActiveScheduleTriggeredEventType,
EventVersion: sharedevents.ActiveScheduleTriggeredEventVersion,
MessageKey: eventPayload.MessageKey(),
AggregateID: eventPayload.AggregateID(),
Payload: eventPayload,
}); err != nil {
_ = s.markTriggerFailed(ctx, row.ID, "outbox_publish_failed", err)
return nil, err
}
return triggerResponseFromModel(row, false), nil
}
func (s *TriggerService) findExistingTrigger(ctx context.Context, userID int, triggerType string, idempotencyKey string, dedupeKey string) (*model.ActiveScheduleTrigger, bool, error) {
if strings.TrimSpace(idempotencyKey) != "" {
existing, err := s.activeDAO.FindTriggerByIdempotencyKey(ctx, userID, triggerType, idempotencyKey)
if err == nil {
return existing, true, nil
}
if !errors.Is(err, gorm.ErrRecordNotFound) {
return nil, false, err
}
}
statuses := []string{
model.ActiveScheduleTriggerStatusPending,
model.ActiveScheduleTriggerStatusProcessing,
model.ActiveScheduleTriggerStatusPreviewGenerated,
}
existing, err := s.activeDAO.FindTriggerByDedupeKey(ctx, dedupeKey, statuses)
if err == nil {
return existing, true, nil
}
if !errors.Is(err, gorm.ErrRecordNotFound) {
return nil, false, err
}
return nil, false, nil
}
func (s *TriggerService) markTriggerFailed(ctx context.Context, triggerID string, code string, err error) error {
message := ""
if err != nil {
message = err.Error()
}
now := s.now()
return s.activeDAO.UpdateTriggerFields(ctx, triggerID, map[string]any{
"status": model.ActiveScheduleTriggerStatusFailed,
"last_error_code": code,
"last_error": &message,
"completed_at": &now,
})
}
func (s *TriggerService) now() time.Time {
if s == nil || s.clock == nil {
return time.Now()
}
return s.clock()
}
// BuildTriggerDedupeKey 生成正式触发去重键。
//
// 说明:
// 1. important_urgent_task 按 30 分钟窗口聚合,避免同一任务反复生成预览;
// 2. unfinished_feedback 优先使用 feedback_id/idempotency_key不做固定时间窗强去重
// 3. 参数非法时仍返回可读字符串,调用方会在 trigger.Validate 阶段拒绝非法输入。
func BuildTriggerDedupeKey(userID int, triggerType trigger.TriggerType, targetType trigger.TargetType, targetID int, feedbackID string, idempotencyKey string, at time.Time) string {
if triggerType == trigger.TriggerTypeUnfinishedFeedback {
return fmt.Sprintf("%d:%s:%s", userID, triggerType, firstNonEmpty(feedbackID, idempotencyKey, fmt.Sprintf("%s:%d", targetType, targetID)))
}
if at.IsZero() {
at = time.Now()
}
windowStart := at.Truncate(triggerDedupeWindow)
return fmt.Sprintf("%d:%s:%s:%d:%s", userID, triggerType, targetType, targetID, windowStart.Format(time.RFC3339))
}
func triggerResponseFromModel(row *model.ActiveScheduleTrigger, dedupeHit bool) *TriggerResponse {
if row == nil {
return &TriggerResponse{DedupeHit: dedupeHit}
}
return &TriggerResponse{
TriggerID: row.ID,
Status: row.Status,
PreviewID: row.PreviewID,
DedupeHit: dedupeHit,
TraceID: row.TraceID,
}
}