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:
Losita
2026-04-30 23:45:27 +08:00
parent e945578fbf
commit 0a014f7472
26 changed files with 3636 additions and 55 deletions

View File

@@ -4,7 +4,6 @@ import (
"context"
"encoding/json"
"errors"
"fmt"
"time"
activeapply "github.com/LoveLosita/smartflow/backend/active_scheduler/apply"
@@ -12,6 +11,7 @@ import (
activepreview "github.com/LoveLosita/smartflow/backend/active_scheduler/preview"
"github.com/LoveLosita/smartflow/backend/dao"
"github.com/LoveLosita/smartflow/backend/model"
"gorm.io/gorm"
)
// PreviewConfirmService 编排第三阶段的预览生成、查询和确认应用。
@@ -88,16 +88,19 @@ func (s *PreviewConfirmService) ConfirmPreview(ctx context.Context, req activeap
}
previewRow, err := s.activeDAO.GetPreviewByID(ctx, req.PreviewID)
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, activeapply.NewApplyError(activeapply.ErrorCodeTargetNotFound, "预览不存在或已被删除", err)
}
return nil, err
}
if previewRow.UserID != req.UserID {
return nil, fmt.Errorf("preview 不属于当前用户")
return nil, activeapply.NewApplyError(activeapply.ErrorCodeForbidden, "预览不属于当前用户", nil)
}
if previewRow.ApplyStatus == model.ActiveScheduleApplyStatusApplied {
if previewRow.ApplyIdempotencyKey == req.IdempotencyKey {
return alreadyAppliedResult(*previewRow), nil
}
return nil, fmt.Errorf("preview 已应用,不能使用新的幂等键重复确认")
return nil, activeapply.NewApplyError(activeapply.ErrorCodeAlreadyApplied, "预览已经应用,不能使用新的幂等键重复确认", nil)
}
applyReq, err := activeapply.ConvertConfirmToApplyRequest(*previewRow, req, now)
@@ -106,7 +109,7 @@ func (s *PreviewConfirmService) ConfirmPreview(ctx context.Context, req activeap
return nil, err
}
if len(applyReq.Commands) == 0 {
return nil, fmt.Errorf("当前候选没有可正式应用的日程变更")
return s.markNoopApplied(ctx, *applyReq)
}
if err = s.markApplying(ctx, *applyReq); err != nil {
return nil, err
@@ -115,8 +118,9 @@ func (s *PreviewConfirmService) ConfirmPreview(ctx context.Context, req activeap
adapterReq := toAdapterRequest(*applyReq)
adapterResult, err := s.applyAdapter.ApplyActiveScheduleChanges(ctx, adapterReq)
if err != nil {
_ = s.markApplyFailed(ctx, previewRow.ID, applyReq.ApplyID, err)
return nil, err
classifiedErr := classifyAdapterApplyError(err)
_ = s.markApplyFailed(ctx, previewRow.ID, applyReq.ApplyID, classifiedErr)
return nil, classifiedErr
}
result := activeapply.ApplyActiveScheduleResult{
@@ -155,13 +159,48 @@ func (s *PreviewConfirmService) markApplying(ctx context.Context, req activeappl
})
}
// markNoopApplied 处理 notify_only / ask_user / close 这类“确认成功但不写正式日程”的候选。
//
// 职责边界:
// 1. 只把 preview 标记为已处理,并保留幂等字段,便于同 key 重试直接命中历史结果;
// 2. 不调用 apply adapter因为这些 change 在转换阶段已经被归类为 skipped_changes
// 3. 失败时直接返回数据库错误,调用方应按系统错误处理,避免前端误以为确认成功。
func (s *PreviewConfirmService) markNoopApplied(ctx context.Context, req activeapply.ApplyActiveScheduleRequest) (*activeapply.ConfirmResult, error) {
result := activeapply.ApplyActiveScheduleResult{
ApplyID: req.ApplyID,
ApplyStatus: activeapply.ApplyStatusApplied,
AppliedChanges: []activeapply.ApplyChange{},
SkippedChanges: req.SkippedChanges,
RequestHash: req.RequestHash,
NormalizedChangeHash: req.NormalizedChangesHash,
}
if err := s.markApplied(ctx, req, result); err != nil {
return nil, err
}
return &activeapply.ConfirmResult{
PreviewID: req.PreviewID,
ApplyID: req.ApplyID,
ApplyStatus: activeapply.ApplyStatusApplied,
CandidateID: req.CandidateID,
RequestHash: req.RequestHash,
RequestBodyHash: req.RequestBodyHash,
ApplyRequest: &req,
ApplyResult: &result,
SkippedChanges: req.SkippedChanges,
}, nil
}
func (s *PreviewConfirmService) markApplied(ctx context.Context, req activeapply.ApplyActiveScheduleRequest, result activeapply.ApplyActiveScheduleResult) error {
now := s.now()
appliedChangesJSON := mustJSON(result.AppliedChanges)
appliedEventIDsJSON := mustJSON(result.AppliedEventIDs)
return s.activeDAO.UpdatePreviewFields(ctx, req.PreviewID, map[string]any{
"status": model.ActiveSchedulePreviewStatusApplied,
"apply_id": req.ApplyID,
"apply_status": model.ActiveScheduleApplyStatusApplied,
"apply_candidate_id": req.CandidateID,
"apply_idempotency_key": req.IdempotencyKey,
"apply_request_hash": req.RequestHash,
"applied_changes_json": &appliedChangesJSON,
"applied_event_ids_json": &appliedEventIDsJSON,
"apply_error": nil,
@@ -177,8 +216,19 @@ func (s *PreviewConfirmService) markApplyFailed(ctx context.Context, previewID s
if err != nil {
message = err.Error()
}
status := model.ActiveScheduleApplyStatusFailed
if applyErr, ok := activeapply.AsApplyError(err); ok {
switch applyErr.Code {
case activeapply.ErrorCodeExpired:
status = model.ActiveScheduleApplyStatusExpired
case activeapply.ErrorCodeDBError:
status = model.ActiveScheduleApplyStatusFailed
default:
status = model.ActiveScheduleApplyStatusRejected
}
}
updates := map[string]any{
"apply_status": model.ActiveScheduleApplyStatusFailed,
"apply_status": status,
"apply_error": &message,
}
if applyID != "" {
@@ -187,6 +237,40 @@ func (s *PreviewConfirmService) markApplyFailed(ctx context.Context, previewID s
return s.activeDAO.UpdatePreviewFields(ctx, previewID, updates)
}
// classifyAdapterApplyError 把正式写库 adapter 的错误转换为 confirm 层统一错误码。
//
// 职责边界:
// 1. 只处理 applyadapter 已声明的业务错误码,保持 API 层只理解 active_scheduler/apply 包;
// 2. 未知错误统一归为 db_error避免把真实系统故障错误映射为用户可修正的 4xx
// 3. 原始错误作为 cause 保留,日志和 apply_error 仍能追到 adapter 返回的完整信息。
func classifyAdapterApplyError(err error) error {
if err == nil {
return nil
}
var adapterErr *applyadapter.ApplyError
if !errors.As(err, &adapterErr) {
return activeapply.NewApplyError(activeapply.ErrorCodeDBError, "主动调度正式写库失败", err)
}
switch adapterErr.Code {
case applyadapter.ErrorCodeInvalidRequest:
return activeapply.NewApplyError(activeapply.ErrorCodeInvalidRequest, adapterErr.Message, err)
case applyadapter.ErrorCodeUnsupportedChangeType:
return activeapply.NewApplyError(activeapply.ErrorCodeUnsupportedChangeType, adapterErr.Message, err)
case applyadapter.ErrorCodeTargetNotFound:
return activeapply.NewApplyError(activeapply.ErrorCodeTargetNotFound, adapterErr.Message, err)
case applyadapter.ErrorCodeTargetCompleted:
return activeapply.NewApplyError(activeapply.ErrorCodeTargetCompleted, adapterErr.Message, err)
case applyadapter.ErrorCodeTargetAlreadyScheduled:
return activeapply.NewApplyError(activeapply.ErrorCodeTargetAlreadySchedule, adapterErr.Message, err)
case applyadapter.ErrorCodeSlotConflict:
return activeapply.NewApplyError(activeapply.ErrorCodeSlotConflict, adapterErr.Message, err)
case applyadapter.ErrorCodeInvalidEditedChanges:
return activeapply.NewApplyError(activeapply.ErrorCodeInvalidEditedChanges, adapterErr.Message, err)
default:
return activeapply.NewApplyError(activeapply.ErrorCodeDBError, adapterErr.Message, err)
}
}
func (s *PreviewConfirmService) now() time.Time {
if s == nil || s.clock == nil {
return time.Now()